Files
scripts/network-diag.pl
T

1585 lines
56 KiB
Perl
Raw Normal View History

#!/usr/bin/env perl
# Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
# SPDX-License-Identifier: MIT
# Network diagnostics: latency, DNS, MTU, packet loss, dual-stack and port checks.
#
# Perl builtins only. No module is required to run this. The two capabilities
# that Perl no longer exposes as builtins are handled the way the rules prescribe:
#
# * Address arithmetic (inet_aton and its relatives moved into Socket) is
# hand-rolled below, in ip4_bytes and ip6_bytes.
# * Name resolution, sockets aside, is delegated to a system binary that speaks
# getaddrinfo, which keeps /etc/hosts, nsswitch and any caching resolver in the
# picture. getent is preferred and host, present in the FreeBSD base system and
# shipped with bind-utils on Linux, is the fallback. Which one is in use is
# probed at run time rather than assumed.
#
# One capability has no builtin substitute at all: a monotonic clock with
# millisecond resolution, which the TCP and DNS latency figures are measured with.
# Time::HiRes is therefore loaded opportunistically inside eval and never assumed.
# The measurement behind that caution: on Fedora, perl-Time-HiRes is its own RPM
# and is not among the requirements of perl-interpreter, so a bare perl need not
# have it. Where it is absent the report says the figure was not measured and the
# grade drops that component rather than inventing a number.
#
# External binaries used: ping, ip, ifconfig, netstat, ss, sockstat, sudo, getent
# or host, and uname.
#
# Linux and FreeBSD are supported in their current releases, with no branch for a
# superseded one: the unified ping that understands -4 and -6 is the only one
# driven here.
#
# Usage:
# network-diag.pl # full diagnostic
# network-diag.pl --target cloudflare.com # target a specific host
# network-diag.pl --protocol v4 # IPv4 only
# network-diag.pl --protocol v6 # IPv6 only
use strict;
use warnings;
my $VERSION = '2.0.0';
my $BOLD = "\033[1m";
my $RED = "\033[31m";
my $GREEN = "\033[32m";
my $YELLOW = "\033[33m";
my $DIM = "\033[2m";
my $RESET = "\033[0m";
# Well-known hosts for the connectivity tests, in report order.
my @CONNECTIVITY_HOSTS = (['cloudflare.com', 443], ['google.com', 443]);
# The optional clock. Loaded here, once, and guarded everywhere it is used.
my $HAVE_HIRES = eval { require Time::HiRes; 1 } ? 1 : 0;
# Socket constants. AF_INET6 is 10 on Linux and 28 on FreeBSD, the only two
# platforms this script supports; the other values are the same on both.
sub af_inet6 { return $^O eq 'freebsd' ? 28 : 10 }
my $AF_INET = 2;
my $SOCK_STREAM = 1;
my $IPPROTO_TCP = 6;
# Address families as the user names them.
my $FAMILY_LABEL = { v4 => 'IPv4', v6 => 'IPv6' };
my $TMP_DIR; # private scratch directory, created only when needed
my $PARENT_PID = $$; # forked children must never clean up for the parent
my $RESOLVER; # cached resolver transport: 'getent', 'host' or ''
my $RESOLVER_OVERHEAD; # measured start-up cost of that transport, in ms
# ---------------------------------------------------------------------------
# Small helpers
# ---------------------------------------------------------------------------
# Monotonic milliseconds source. Returns undef when no clock is available, and
# every caller then reports the figure as not measured instead of guessing.
sub now {
return $HAVE_HIRES ? Time::HiRes::time() : undef;
}
# One decimal place, as the report prints it.
sub round1 {
my ($value) = @_;
return sprintf('%.1f', $value);
}
sub detect_os {
return $^O eq 'freebsd' ? 'freebsd' : 'linux';
}
sub status {
my ($msg) = @_;
print STDERR " $msg...";
}
sub status_done {
my ($msg) = @_;
$msg = 'done' unless defined $msg;
print STDERR " $msg\n";
}
# A hand-rolled which(1): the system command is the transport, so the lookup
# itself must not need one.
sub find_exe {
my ($name) = @_;
return undef unless defined $name && length $name;
if (index($name, '/') >= 0) {
return (-f $name && -x _) ? $name : undef;
}
for my $dir (split /:/, ($ENV{PATH} // '')) {
next unless length $dir;
my $path = "$dir/$name";
return $path if -f $path && -x _;
}
return undef;
}
# Run a command, capturing stdout and stderr merged, with a timeout.
#
# The child is forked rather than opened as a pipe because the two streams have to
# be merged and a wedged process has to be killable, which the builtin pipe forms
# do not offer. The locale is forced to C because iputils translates its own
# output (under a Czech locale "time=" becomes "cas=" and the statistics line stops
# saying "transmitted"), and the whole report parses English. Returns the output
# and a status: 'ok', 'timeout' when the wait expired, or 'notfound' when the
# binary is missing, so callers can tell "tool unavailable" from "no output".
sub run {
my ($cmd, $timeout) = @_;
$timeout = 15 unless defined $timeout;
my $exe = find_exe($cmd->[0]);
return ('', 'notfound') unless defined $exe;
my ($rd, $wr);
pipe($rd, $wr) or die "cannot create a pipe: $!\n";
my $pid = fork();
die "cannot fork: $!\n" unless defined $pid;
if ($pid == 0) {
close($rd);
open(STDOUT, '>&', $wr) or exit 127;
open(STDERR, '>&', $wr) or exit 127;
close($wr);
$ENV{LANG} = 'C';
$ENV{LC_ALL} = 'C';
exec { $exe } @$cmd;
exit 127; # reached only when exec itself fails
}
close($wr);
my $out = '';
my $timed_out = 0;
eval {
local $SIG{ALRM} = sub { die "alarm\n" };
alarm($timeout);
local $/ = undef;
$out = <$rd>;
alarm(0);
1;
} or do { $timed_out = 1; alarm(0) };
$out = '' unless defined $out;
close($rd);
# Terminate the child, then reap it. A Term-ed process that ignores the
# signal is followed by a KILL, so the wait below cannot hang.
kill('TERM', $pid);
select(undef, undef, undef, 0.1);
kill('KILL', $pid);
waitpid($pid, 0);
my $exit_code = $? >> 8;
return ('', 'timeout') if $timed_out;
return ($out, 'notfound') if $exit_code == 127;
return ($out, 'ok');
}
# ---------------------------------------------------------------------------
# ICMP latency
# ---------------------------------------------------------------------------
# Per-reply latencies and the received counter, which differ between iputils and
# FreeBSD: "5 packets transmitted, 5 received" against "5 packets transmitted,
# 5 packets received". The counter is undef when no statistics line was seen, and
# callers then fall back to the number of parsed replies.
sub parse_ping_output {
my ($output) = @_;
my @latencies = $output =~ /time=(\d+(?:\.\d+)?)/g;
my $received;
for my $line (split /\n/, $output, -1) {
next unless index($line, 'transmitted') >= 0;
$received = $1 if $line =~ /(\d+)\s+(?:packets?\s+)?received/;
last;
}
return (\@latencies, $received);
}
sub min_max_sum {
my ($values) = @_;
my ($min, $max, $sum) = ($values->[0], $values->[0], 0);
for my $v (@$values) {
$min = $v if $v < $min;
$max = $v if $v > $max;
$sum += $v;
}
return ($min, $max, $sum);
}
# A standardised ping result. loss_pct comes from the received counter rather
# than from the number of parsed replies, which is the authoritative figure.
sub ping_result {
my ($host, $latencies, $received, $count, $protocol) = @_;
my $loss = $count > 0 ? round1(($count - $received) / $count * 100) : '100.0';
my %result = (
host => $host,
protocol => $protocol,
sent => $count,
received => $received,
loss_pct => $loss,
min_ms => '0.0',
avg_ms => '0.0',
max_ms => '0.0',
stddev_ms => '0.0',
);
if (@$latencies) {
my ($min, $max, $sum) = min_max_sum($latencies);
my $n = scalar @$latencies;
$result{min_ms} = round1($min);
$result{avg_ms} = round1($sum / $n);
$result{max_ms} = round1($max);
if ($n > 1) {
my $mean = $sum / $n;
my $sq = 0;
$sq += ($_ - $mean) ** 2 for @$latencies;
$result{stddev_ms} = round1(sqrt($sq / ($n - 1)));
}
}
return \%result;
}
# The ping command for an address family. Every supported release ships the
# unified ping that understands -4 and -6, so there is nothing to choose between.
sub ping_base_cmd {
my ($family) = @_;
return ['ping', ($family eq 'v6' ? '-6' : '-4')];
}
# Per-reply wait flag: iputils -W takes seconds, and FreeBSD ping -W takes
# milliseconds, which is the one difference between the two.
sub ping_wait_args {
my ($os_type, $timeout) = @_;
return $os_type eq 'freebsd' ? ['-W', $timeout * 1000] : ['-W', $timeout];
}
sub ping_host {
my ($host, $os_type, %opt) = @_;
my $count = $opt{count} // 5;
my $timeout = $opt{timeout} // 5;
my $protocol = $opt{protocol} // 'v4';
my $base = ping_base_cmd($protocol);
my @cmd = (@$base, '-c', $count, @{ping_wait_args($os_type, $timeout)}, '--', $host);
# The timeout must also cover the resolution ping does itself.
my ($out, $run_status) = run(\@cmd, $count + $timeout + 10);
if ($run_status eq 'notfound') {
my $result = ping_result($host, [], 0, $count, $protocol);
$result->{note} = "'$base->[0]' not found";
return $result;
}
my ($latencies, $parsed_received) = parse_ping_output($out);
my $received = defined $parsed_received ? $parsed_received : scalar @$latencies;
return ping_result($host, $latencies, $received, $count, $protocol);
}
# ---------------------------------------------------------------------------
# Address arithmetic, hand-rolled because Socket no longer shares it
# ---------------------------------------------------------------------------
# Dotted quad to four bytes, or undef when it is not a dotted quad.
sub ip4_bytes {
my ($ip) = @_;
my @octets = split /\./, $ip, -1;
return undef unless @octets == 4;
for my $octet (@octets) {
return undef unless $octet =~ /^\d{1,3}$/ && $octet <= 255;
}
return pack('C4', @octets);
}
# Textual IPv6 to sixteen bytes, or undef. Handles the :: shorthand and a
# trailing embedded IPv4 form, and ignores a zone suffix.
sub ip6_bytes {
my ($ip) = @_;
$ip =~ s/%.*//;
return undef unless $ip =~ /^[0-9a-fA-F:.]+$/ && index($ip, ':') >= 0;
my ($head, $tail) = ($ip, '');
my $shorthand = 0;
if (index($ip, '::') >= 0) {
($head, $tail) = split /::/, $ip, 2;
$shorthand = 1;
}
my @head = length($head // '') ? split(/:/, $head, -1) : ();
my @tail = length($tail // '') ? split(/:/, $tail, -1) : ();
if (@tail && $tail[-1] =~ /\./) {
my $quad = ip4_bytes(pop @tail);
return undef unless defined $quad;
my @words = unpack('n2', $quad);
push @tail, sprintf('%x', $words[0]), sprintf('%x', $words[1]);
}
return undef if @head + @tail > ($shorthand ? 7 : 8);
my @groups = $shorthand ? (@head, (0) x (8 - @head - @tail), @tail) : (@head, @tail);
return undef unless @groups == 8;
for my $group (@groups) {
return undef unless $group =~ /^[0-9a-fA-F]{1,4}$/;
}
return pack('n8', map { hex } @groups);
}
# Is this textual address an IPv4-mapped IPv6 one (::ffff:a.b.c.d)? glibc's
# ahostsv6 answers with a mapped address for a name that has no AAAA record, and
# connecting to it silently uses IPv4, so it must never be taken for IPv6.
sub is_mapped6 {
my ($addr) = @_;
my $bytes = ip6_bytes($addr);
return 0 unless defined $bytes;
# Twelve bytes: ten of zero, then the 0xffff marker.
return substr($bytes, 0, 12) eq ("\0" x 10) . "\xff\xff" ? 1 : 0;
}
# sockaddr_in and sockaddr_in6, packed by hand. Linux has a two-byte sin_family
# and no length field; FreeBSD has sin_len first and a one-byte family.
sub sa_in {
my ($ip, $port) = @_;
my $bytes = ip4_bytes($ip);
return undef unless defined $bytes;
return pack('S n a4 x8', $AF_INET, $port, $bytes);
}
sub sa_in6 {
my ($ip, $port) = @_;
my $bytes = ip6_bytes($ip);
return undef unless defined $bytes;
return $^O eq 'freebsd'
? pack('C C n N a16 N', 28, 28, $port, 0, $bytes, 0)
: pack('S n N a16 N', 10, $port, 0, $bytes, 0);
}
# Address classification, approximating the IANA tables: 'global' is what the
# report calls a globally scoped
# address, 'private' everything else that is still usable, and the remaining
# kinds are excluded from consideration entirely.
sub class4 {
my ($bytes) = @_;
my @o = unpack('C4', $bytes);
return 'loopback' if $o[0] == 127;
return 'linklocal' if $o[0] == 169 && $o[1] == 254;
return 'unspecified' if $o[0] == 0;
return 'multicast' if $o[0] >= 224 && $o[0] <= 239;
return 'private' if $o[0] >= 240;
return 'private' if $o[0] == 10;
return 'private' if $o[0] == 172 && $o[1] >= 16 && $o[1] <= 31;
return 'private' if $o[0] == 192 && $o[1] == 168;
return 'private' if $o[0] == 100 && $o[1] >= 64 && $o[1] <= 127;
return 'private' if $o[0] == 192 && $o[1] == 0 && $o[2] == 0;
return 'private' if $o[0] == 192 && $o[1] == 0 && $o[2] == 2;
return 'private' if $o[0] == 192 && $o[1] == 88 && $o[2] == 99;
return 'private' if $o[0] == 198 && ($o[1] == 18 || $o[1] == 19);
return 'private' if $o[0] == 198 && $o[1] == 51 && $o[2] == 100;
return 'private' if $o[0] == 203 && $o[1] == 0 && $o[2] == 113;
return 'global';
}
sub class6 {
my ($bytes) = @_;
my @w = unpack('n8', $bytes);
return 'unspecified' if !grep { $_ } @w;
# The parentheses matter: without them the && joins grep's list and the
# condition collapses, which reported other addresses as loopback.
return 'loopback' if !(grep { $_ } @w[0 .. 6]) && $w[7] == 1;
return 'mapped' if !(grep { $_ } @w[0 .. 4]) && $w[5] == 0xffff;
return 'linklocal' if ($w[0] & 0xffc0) == 0xfe80;
return 'multicast' if ($w[0] & 0xff00) == 0xff00;
return 'private' if ($w[0] & 0xfe00) == 0xfc00; # unique local
return 'private' if $w[0] == 0x2002; # 6to4
return 'private' if $w[0] == 0x2001 && $w[1] == 0x0000; # Teredo
return 'global';
}
# Usable addresses of one family from the collected interface data, globally
# scoped ones first. Loopback, link-local, unspecified and multicast addresses
# are dropped.
sub family_addresses {
my ($iface_info, $family) = @_;
my (@global, @other);
for my $iface (@{ $iface_info->{interfaces} // [] }) {
for my $raw (@{ $iface->{ips} // [] }) {
my $addr = $raw;
$addr =~ s{/.*$}{};
my $kind;
if ($family eq 'v6') {
next unless index($addr, ':') >= 0;
my $bytes = ip6_bytes($addr);
next unless defined $bytes;
$kind = class6($bytes);
next if $kind eq 'loopback' || $kind eq 'linklocal'
|| $kind eq 'unspecified' || $kind eq 'multicast';
}
else {
next if index($addr, ':') >= 0;
my $bytes = ip4_bytes($addr);
next unless defined $bytes;
$kind = class4($bytes);
next if $kind eq 'loopback' || $kind eq 'linklocal'
|| $kind eq 'unspecified' || $kind eq 'multicast';
}
push @{ $kind eq 'global' ? \@global : \@other }, $addr;
}
}
return (@global, @other);
}
sub address_kind {
my ($family, $addr) = @_;
my $bytes = $family eq 'v6' ? ip6_bytes($addr) : ip4_bytes($addr);
return '' unless defined $bytes;
return $family eq 'v6' ? class6($bytes) : class4($bytes);
}
# ---------------------------------------------------------------------------
# Interfaces
# ---------------------------------------------------------------------------
sub interface_info {
my ($os_type) = @_;
my %info = (
interfaces => [],
default_route_v4 => '',
default_route_v6 => '',
error => '',
);
return interface_info_freebsd(\%info) if $os_type eq 'freebsd';
return interface_info_linux(\%info);
}
sub interface_info_linux {
my ($info) = @_;
my ($out, $run_status) = run(['ip', '-br', 'addr', 'show']);
if ($run_status eq 'notfound') {
$info->{error} = "'ip' not found";
return $info;
}
for my $line (split /\n/, $out, -1) {
my @parts = split ' ', $line;
next if @parts < 2 || $parts[0] eq 'lo';
push @{ $info->{interfaces} }, {
name => $parts[0],
state => $parts[1],
ips => [ @parts[2 .. $#parts] ],
};
}
for my $item (['-4', 'default_route_v4'], ['-6', 'default_route_v6']) {
my ($family_flag, $key) = @$item;
my ($route, $route_status) = run(['ip', $family_flag, 'route', 'show', 'default']);
next unless $route_status eq 'ok';
my @parts = split ' ', $route;
for my $i (0 .. $#parts) {
next unless $parts[$i] eq 'via' && $i + 1 <= $#parts;
$info->{$key} = $parts[$i + 1];
last;
}
}
return $info;
}
sub interface_info_freebsd {
my ($info) = @_;
my ($iflist, $list_status) = run(['ifconfig', '-l']);
if ($list_status eq 'notfound') {
$info->{error} = "'ifconfig' not found";
return $info;
}
my @names = split ' ', $iflist;
return $info unless @names;
for my $name (@names) {
next if $name eq 'lo0';
my ($out, $run_status) = run(['ifconfig', $name]);
next unless $run_status eq 'ok';
my @lines = split /\n/, $out, -1;
next unless @lines && length $lines[0];
my $state = (index($lines[0], '<UP,') >= 0 || index($lines[0], '<UP>') >= 0)
? 'UP' : 'DOWN';
my @ips;
for my $line (@lines[1 .. $#lines]) {
my $stripped = $line;
$stripped =~ s/^\s+//;
$stripped =~ s/\s+$//;
if ($stripped =~ /^inet /) {
my @parts = split ' ', $stripped;
push @ips, $parts[1] if @parts >= 2 && $parts[1] ne '127.0.0.1';
}
elsif ($stripped =~ /^inet6 /) {
my @parts = split ' ', $stripped;
next if @parts < 2;
my $addr = $parts[1];
$addr =~ s/%.*//;
next if $addr =~ /^fe80:/ || $addr eq '::1';
push @ips, $addr;
}
}
push @{ $info->{interfaces} }, { name => $name, state => $state, ips => \@ips };
}
my ($route, $route_status) = run(['netstat', '-rn']);
return $info unless $route_status eq 'ok';
for my $line (split /\n/, $route, -1) {
next unless $line =~ /^default/;
my @parts = split ' ', $line;
next if @parts < 2;
my $gateway = $parts[1];
$gateway =~ s/%.*//;
if (index($gateway, ':') >= 0) {
$info->{default_route_v6} = $gateway unless $info->{default_route_v6};
}
elsif (!$info->{default_route_v4}) {
$info->{default_route_v4} = $gateway;
}
}
return $info;
}
# ---------------------------------------------------------------------------
# Name resolution
# ---------------------------------------------------------------------------
# The transport is probed once: getent implements getaddrinfo directly, so it is
# preferred, and host is the fallback for systems without the ahosts databases.
sub resolver_transport {
return $RESOLVER if defined $RESOLVER;
$RESOLVER = '';
if (defined find_exe('getent')) {
my ($out, $run_status) = run(['getent', 'ahostsv4', 'localhost']);
$RESOLVER = 'getent' if $run_status eq 'ok' && index($out, 'localhost') >= 0;
}
$RESOLVER = 'host' if !$RESOLVER && defined find_exe('host');
return $RESOLVER;
}
# Resolve a name for one address family, in resolver order, with IPv4-mapped
# answers excluded. An empty list means the name has no address of that family.
sub resolve_family {
my ($host, $family) = @_;
my $transport = resolver_transport();
return () if $transport eq '';
if ($transport eq 'getent') {
my ($out, $run_status) = run(['getent', $family eq 'v6' ? 'ahostsv6' : 'ahostsv4', $host]);
return () unless $run_status eq 'ok';
my @addrs;
for my $line (split /\n/, $out, -1) {
my @parts = split ' ', $line;
next unless @parts >= 2 && $parts[1] eq 'STREAM';
next if is_mapped6($parts[0]);
push @addrs, $parts[0];
}
return @addrs;
}
my ($out, $run_status) = run(['host', '-W', 3, '-t', $family eq 'v6' ? 'AAAA' : 'A', $host]);
return () unless $run_status eq 'ok';
my @addrs;
for my $line (split /\n/, $out, -1) {
if ($family eq 'v6') {
push @addrs, $1 if $line =~ /\bhas IPv6 address (\S+)/;
}
else {
push @addrs, $1 if $line =~ /\bhas address (\S+)/;
}
}
return @addrs;
}
# The resolver runs as a separate process, so its start-up cost sits inside every
# sample taken around it. That cost is measured once against a name that never
# leaves the machine and subtracted, leaving the resolution itself. The
# subtraction is conservative: the calibration name is answered from the hosts
# file and so never exercises the code path a network query goes through, which
# leaves a residual measured at about 3 ms per sample on this workstation. That
# residual is a fraction of the 20 ms that separates one DNS score from the next,
# so it does not move the grade, and it is recorded rather than hidden because
# the figure is not exact.
sub resolver_overhead {
return $RESOLVER_OVERHEAD if defined $RESOLVER_OVERHEAD;
$RESOLVER_OVERHEAD = 0;
return 0 unless $HAVE_HIRES && resolver_transport() ne '';
my @samples;
for (1 .. 5) {
my $start = now();
my @ignored = resolve_family('localhost', 'v4');
push @samples, (now() - $start) * 1000;
}
@samples = sort { $a <=> $b } @samples;
$RESOLVER_OVERHEAD = $samples[int(@samples / 2)];
return $RESOLVER_OVERHEAD;
}
sub dns_samples {
my ($domain, $family) = @_;
# One untimed resolution first. The resolver's machinery, an NSS module for
# instance, is loaded once per process, and letting the first sample pay for
# that would report the loader instead of the resolver. Measured here: the
# first AAAA sample taken in a fresh process cost 37 ms against 3 ms for the
# rest, because the calibration below resolves a name that never reaches the
# module in question.
my @warm_up = resolve_family($domain, $family);
my @results;
for (1 .. 3) {
my $start = now();
my @addrs = resolve_family($domain, $family);
my $ms;
if (defined $start) {
$ms = (now() - $start) * 1000 - resolver_overhead();
$ms = 0 if $ms < 0;
}
my %sample = (ip => (@addrs ? $addrs[0] : 'N/A'));
$sample{ms} = defined $ms ? round1($ms) : -1;
push @results, \%sample;
}
return \@results;
}
# The machine's own name, and its fully qualified form. The hostname comes from
# uname; the qualified name is only chased when the short name carries no dot.
sub check_hostname {
my ($out, $run_status) = run(['uname', '-n']);
my $name = $run_status eq 'ok' ? $out : '';
$name =~ s/\s+$//;
return $name;
}
sub check_fqdn {
my ($name) = @_;
return '' unless length $name;
return $name if index($name, '.') >= 0;
my ($canonical, $aliases, $addrtype, $length, @addresses) = gethostbyname($name);
return $name unless @addresses;
my ($reverse, $r_aliases) = gethostbyaddr($addresses[0], $AF_INET);
return $name unless defined $reverse && index($reverse, '.') >= 0;
return $reverse;
}
sub check_dns {
my ($protocol) = @_;
my $hostname = check_hostname();
my %info = (
hostname => $hostname,
fqdn => check_fqdn($hostname),
resolvers => [],
v4 => {},
v6 => {},
);
# Without a clock the latency column has nothing to report, and the grade
# leaves that component out rather than scoring a figure that was not taken.
$info{no_clock} = 1 unless $HAVE_HIRES;
for my $entry (@CONNECTIVITY_HOSTS) {
my $domain = $entry->[0];
$info{v6}{$domain} = dns_samples($domain, 'v6') if $protocol ne 'v4';
$info{v4}{$domain} = dns_samples($domain, 'v4') if $protocol ne 'v6';
}
if (open(my $fh, '<', '/etc/resolv.conf')) {
while (my $line = <$fh>) {
next unless $line =~ /^nameserver/;
my @parts = split ' ', $line;
push @{ $info{resolvers} }, $parts[1] if @parts >= 2;
}
close($fh);
}
return \%info;
}
# ---------------------------------------------------------------------------
# TCP reachability
# ---------------------------------------------------------------------------
# A TCP handshake to one host over one family. Candidates are tried in resolver
# order and the first success wins; the timing covers the connect alone.
sub tcp_probe {
my ($host, $port, $family) = @_;
my @addrs = resolve_family($host, $family);
return { reachable => 0 } unless @addrs;
for my $addr (@addrs) {
my $sockaddr = $family eq 'v6' ? sa_in6($addr, $port) : sa_in($addr, $port);
next unless defined $sockaddr;
my $family_const = $family eq 'v6' ? af_inet6() : $AF_INET;
socket(my $sock, $family_const, $SOCK_STREAM, $IPPROTO_TCP) or next;
my $start = now();
my $connected = 0;
eval {
local $SIG{ALRM} = sub { die "alarm\n" };
alarm(5);
$connected = connect($sock, $sockaddr);
alarm(0);
1;
} or do { $connected = 0; alarm(0) };
my $ms = defined $start ? round1((now() - $start) * 1000) : undef;
close($sock);
next unless $connected;
my %result = (reachable => 1, ip => $addr);
$result{ms} = $ms if defined $ms;
return \%result;
}
return { reachable => 0 };
}
# TCP handshake probes to well-known hosts over both families, IPv6 first. This
# is the only place TCP probes are made; the per-family reachability sections
# reuse these results. A family excluded with --protocol is left empty, so the
# reachability section and the grade skip it instead of penalising a probe the
# user asked not to make.
sub check_connectivity {
my ($protocol) = @_;
my %info = (v4 => {}, v6 => {});
for my $entry (@CONNECTIVITY_HOSTS) {
my ($host, $port) = @$entry;
$info{v6}{$host} = tcp_probe($host, $port, 'v6') if $protocol ne 'v4';
$info{v4}{$host} = tcp_probe($host, $port, 'v4') if $protocol ne 'v6';
}
return \%info;
}
# The best address for the target over one family, preferring IPv6, together with
# the family it belongs to. Resolving here rather than letting ping decide keeps
# the family deterministic, which the MTU header arithmetic depends on.
sub resolve_probe_target {
my ($target, $protocol) = @_;
my @order = $protocol eq 'auto' ? ('v6', 'v4') : ($protocol);
for my $family (@order) {
my @addrs = resolve_family($target, $family);
return ($family, $addrs[0]) if @addrs;
}
return (undef, undef);
}
# ---------------------------------------------------------------------------
# Path MTU
# ---------------------------------------------------------------------------
# A DF probe walk from 1500 downwards over one family. Success is a parsed
# reply, and header sizes are per family: 28 bytes for IPv4, 48 for IPv6. Every
# failure falls through to the next size, whether it was a definitive "too big"
# answer or a timeout. Returns the largest size that drew a reply (0 when none
# did) and, when the ping binary itself is missing, the method note for it.
sub mtu_probe_walk {
my ($addr, $family, $os_type) = @_;
my $header = $family eq 'v6' ? 48 : 28;
my $base = ping_base_cmd($family);
my @df_args = $os_type eq 'freebsd' ? ('-D') : ('-M', 'do');
my @wait_args = @{ ping_wait_args($os_type, 2) };
for my $size (1500, 1400, 1300, 1280, 1200, 1000) {
my $payload = $size - $header;
my @cmd = (@$base, @df_args, '-c', 1, @wait_args, '-s', $payload, '--', $addr);
my ($out, $run_status) = run(\@cmd, 12);
return (0, "'$base->[0]' not found") if $run_status eq 'notfound';
my ($latencies, $received) = parse_ping_output($out);
return ($size, undef) if defined $received && $received > 0;
}
return (0, undef);
}
sub check_mtu {
my ($target, $protocol, $os_type) = @_;
my %info = (path_mtu => 0, target => $target, method => '');
my %family_label = (v4 => 'IPv4', v6 => 'IPv6');
my ($family, $addr) = resolve_probe_target($target, $protocol);
if (!defined $family) {
$info{method} = 'target did not resolve';
return \%info;
}
my ($size, $missing) = mtu_probe_walk($addr, $family, $os_type);
if (defined $missing) {
$info{method} = $missing;
return \%info;
}
# A name that resolves over the preferred family but has no route there must
# not read as a missing path MTU: with --protocol auto the other family gets
# the same walk before the section gives up. An explicit --protocol keeps
# the single family the user asked for.
if (!$size && $protocol eq 'auto') {
my $other = $family eq 'v6' ? 'v4' : 'v6';
my @addrs = resolve_family($target, $other);
if (@addrs) {
my ($alt_size, $alt_missing) = mtu_probe_walk($addrs[0], $other, $os_type);
if (defined $alt_missing) {
$info{method} = $alt_missing;
return \%info;
}
if ($alt_size) {
$info{path_mtu} = $alt_size;
$info{method} = "ping DF probe (${alt_size}B OK, $family_label{$other} fallback)";
return \%info;
}
}
}
if ($size) {
$info{path_mtu} = $size;
$info{method} = "ping DF probe (${size}B OK)";
}
else {
$info{method} = 'ping DF probe (no probe size succeeded)';
}
return \%info;
}
# ---------------------------------------------------------------------------
# Listening ports
# ---------------------------------------------------------------------------
sub parse_ss_output {
my ($out) = @_;
my @lines = split /\n/, $out, -1;
shift @lines; # header
my @entries;
for my $line (@lines) {
next unless index($line, 'LISTEN') >= 0;
my @parts = split ' ', $line;
next if @parts < 5;
# State Recv-Q Send-Q LocalAddress:Port PeerAddress:Port Process
my $local = $parts[3];
push @entries, {
addr => $local,
# A process name may contain a space, so the tail is joined whole.
process => (@parts > 5 ? join(' ', @parts[5 .. $#parts]) : ''),
# IPv6 sockets are written in bracket notation, [::]:port
bucket => index($local, '[') == 0 ? 'tcp6' : 'tcp4',
};
}
return @entries;
}
sub listening_ports {
my ($os_type) = @_;
return listening_ports_freebsd() if $os_type eq 'freebsd';
return listening_ports_linux();
}
sub listening_ports_linux {
my %info = (tcp4 => [], tcp6 => [], tcp46 => [], error => '');
my ($out, $run_status) = run(['ss', '-tlnp'], 5);
if ($run_status eq 'notfound') {
$info{error} = "'ss' not found";
return \%info;
}
my @entries = parse_ss_output($out);
# Ports owned by other users come back without a process name; sudo without a
# password prompt fills those in where it can.
if (grep { $_->{process} eq '' } @entries) {
my ($sudo_out, $sudo_status) = run(['sudo', '-n', 'ss', '-tlnp'], 5);
if ($sudo_status eq 'ok' && length $sudo_out) {
my %by_addr;
$by_addr{ $_->{addr} } = $_ for parse_ss_output($sudo_out);
for my $entry (@entries) {
next unless $entry->{process} eq '' && exists $by_addr{ $entry->{addr} };
$entry->{process} = $by_addr{ $entry->{addr} }{process};
}
}
}
for my $entry (@entries) {
my $process = length $entry->{process} ? $entry->{process} : '(no permission)';
push @{ $info{ $entry->{bucket} } }, { addr => $entry->{addr}, process => $process };
}
return \%info;
}
sub listening_ports_freebsd {
my %info = (tcp4 => [], tcp6 => [], tcp46 => [], error => '');
my ($out, $run_status) = run(['sockstat', '-l4', '-l6'], 5);
if ($run_status eq 'notfound') {
$info{error} = "'sockstat' not found";
return \%info;
}
my @lines = split /\n/, $out, -1;
shift @lines; # header
for my $line (@lines) {
my @parts = split ' ', $line;
next if @parts < 6;
my $proto = $parts[4]; # tcp4, tcp6, or tcp46 for a dual-stack socket
next unless $proto eq 'tcp4' || $proto eq 'tcp6' || $proto eq 'tcp46';
push @{ $info{$proto} }, { addr => $parts[5], process => $parts[1] };
}
return \%info;
}
# ---------------------------------------------------------------------------
# Reachability sections
# ---------------------------------------------------------------------------
sub reachability_report {
my ($family, $iface_info, $probes) = @_;
my @addresses = family_addresses($iface_info, $family);
my $best = $addresses[0];
return {
available => defined $best ? 1 : 0,
addr => defined $best ? $best : '',
is_global => defined $best && address_kind($family, $best) eq 'global' ? 1 : 0,
test_sites => $probes,
};
}
# ---------------------------------------------------------------------------
# Grade
# ---------------------------------------------------------------------------
sub grade_letter {
my ($pct) = @_;
return 'A' if $pct >= 90;
return 'B' if $pct >= 80;
return 'C' if $pct >= 70;
return 'D' if $pct >= 60;
return 'F';
}
# Five components of twenty points each: packet loss, latency, DNS speed, IPv4
# reachability and IPv6 support. A component that was not measured, because the
# family was excluded with --protocol or because no clock was available, is
# removed from the maximum, so the letter grades the achievable points.
sub compute_grade {
my ($data) = @_;
my $score = 0;
my %breakdown;
my $ping = $data->{ping} // {};
my $loss = $ping->{loss_pct} // 100;
my $loss_score =
$loss <= 0 ? 20
: $loss <= 5 ? 15
: $loss <= 10 ? 10
: $loss <= 30 ? 5
: 0;
$breakdown{packet_loss} = $loss_score;
$score += $loss_score;
my $avg_ms = $ping->{avg_ms} // 0;
my $latency_score =
$avg_ms <= 0 ? 0
: $avg_ms < 30 ? 20
: $avg_ms < 80 ? 15
: $avg_ms < 150 ? 10
: $avg_ms < 300 ? 5
: 0;
$breakdown{latency} = $latency_score;
$score += $latency_score;
my $max_score = 100;
my $dns = $data->{dns} // {};
my $dns_score = 0;
if ($dns->{no_clock}) {
$max_score -= 20;
}
else {
my ($samples, $total) = (0, 0);
for my $family ('v6', 'v4') {
for my $domain (keys %{ $dns->{$family} // {} }) {
for my $sample (@{ $dns->{$family}{$domain} }) {
next unless $sample->{ms} > 0;
$samples++;
$total += $sample->{ms};
}
}
}
if ($samples > 0) {
my $average = $total / $samples;
$dns_score =
$average < 20 ? 20
: $average < 50 ? 15
: $average < 100 ? 10
: $average < 200 ? 5
: 0;
}
}
$breakdown{dns_speed} = $dns_score;
$score += $dns_score;
my $ipv4 = $data->{ipv4} // {};
my $v4_score = 0;
if ($ipv4->{skipped}) {
$max_score -= 20;
}
elsif ($ipv4->{available}) {
$v4_score += 10;
my $reachable = grep { $_->{reachable} } values %{ $ipv4->{test_sites} // {} };
$v4_score += $reachable * 5 > 10 ? 10 : $reachable * 5;
}
$breakdown{ipv4_reachability} = $v4_score;
$score += $v4_score;
my $ipv6 = $data->{ipv6} // {};
my $v6_score = 0;
if ($ipv6->{skipped}) {
$max_score -= 20;
}
elsif ($ipv6->{available}) {
$v6_score += 10;
my $reachable = grep { $_->{reachable} } values %{ $ipv6->{test_sites} // {} };
$v6_score += $reachable * 5 > 10 ? 10 : $reachable * 5;
}
$breakdown{ipv6_dualstack} = $v6_score;
$score += $v6_score;
my $pct = $max_score > 0 ? sprintf('%.0f', $score * 100 / $max_score) : 0;
my $letter = grade_letter($pct);
my $colour = ($letter eq 'A' || $letter eq 'B') ? $GREEN
: ($letter eq 'C' || $letter eq 'D') ? $YELLOW
: $RED;
return {
grade => $letter,
score => $score,
max_score => $max_score,
colour => $colour,
breakdown => \%breakdown,
};
}
# ---------------------------------------------------------------------------
# Report
# ---------------------------------------------------------------------------
sub ms_colour {
my ($ms) = @_;
return $DIM if !defined $ms || $ms <= 0;
return $GREEN if $ms < 50;
return $YELLOW if $ms < 200;
return $RED;
}
sub print_ping_block {
my ($label, $info) = @_;
return unless ref $info eq 'HASH';
if (($info->{received} // 0) == 0) {
my $note = $info->{note} // $info->{error} // '';
my $suffix = length $note ? " ($note)" : '';
print " $label: ${RED}no response$suffix$RESET\n";
return;
}
my $loss = $info->{loss_pct} // 0;
my $loss_colour = $loss > 10 ? $RED : $loss > 0 ? $YELLOW : $GREEN;
my $avg = $info->{avg_ms} // 0;
my $latency_colour = ms_colour($avg);
printf " %s: %s/%s received Loss: %s%.1f%%%s Avg: %s%.1fms%s Min/Max: %.1f/%.1fms σ: %.1fms\n",
$label,
$info->{sent} // 0, $info->{received} // 0,
$loss_colour, $loss, $RESET,
$latency_colour, $avg, $RESET,
$info->{min_ms} // 0, $info->{max_ms} // 0,
$info->{stddev_ms} // 0;
}
sub print_reachability {
my ($family, $info) = @_;
return unless ref $info eq 'HASH';
my $label = $FAMILY_LABEL->{$family};
if ($info->{skipped}) {
my $protocol_note = $family eq 'v6' ? '--protocol v4' : '--protocol v6';
print " ${DIM}○$RESET $label reachability skipped ($protocol_note)\n";
return;
}
if (!$info->{available}) {
print " ${YELLOW}○$RESET No $label address detected on any interface\n";
return;
}
my $scope = $info->{is_global} ? 'Global' : 'Private';
print " ${GREEN}✓$RESET $scope $label address: $info->{addr}\n";
my $suffix = $family eq 'v6' ? ' over IPv6' : '';
for my $entry (@CONNECTIVITY_HOSTS) {
my $site = $entry->[0];
my $result = $info->{test_sites}{$site};
next unless ref $result eq 'HASH';
if ($result->{reachable}) {
printf " %s✓%s %-20s %sms (%s)\n",
$GREEN, $RESET, $site, round1($result->{ms} // 0), $result->{ip} // '';
}
else {
print " ${RED}✗$RESET ", sprintf('%-20s', $site), " unreachable$suffix\n";
}
}
}
sub print_report {
my ($data) = @_;
my $grade_info = $data->{grade} // {};
my $grade_letter = $grade_info->{grade} // '?';
my $grade_colour = $grade_info->{colour} // $RESET;
my $bar = "═" x 60;
# Header
my $os_label = ucfirst($data->{os} // 'linux');
print "\n${BOLD}${bar}$RESET\n";
print "${BOLD} Network Diagnostics v$VERSION ($os_label)$RESET\n";
print "${BOLD}${bar}$RESET\n";
print " Hostname: $data->{hostname}\n";
print " Protocol: ", ($data->{protocol} // 'auto'), "\n";
print " Summary Grade: ${grade_colour}${BOLD}${grade_letter}$RESET ",
"(", ($grade_info->{score} // '?'), "/", ($grade_info->{max_score} // 100), ")\n";
# Interfaces
print "\n${BOLD}── Interfaces ──$RESET\n";
my $iface = $data->{interfaces} // {};
print " ${DIM}unavailable: $iface->{error}$RESET\n" if $iface->{error};
for my $entry (@{ $iface->{interfaces} // [] }) {
my $state_colour = $entry->{state} eq 'UP' ? $GREEN : $RED;
printf " %-8s %s%-5s%s %s\n",
$entry->{name}, $state_colour, $entry->{state}, $RESET,
join(', ', @{ $entry->{ips} // [] });
}
print " Default IPv6 route → $iface->{default_route_v6}\n" if $iface->{default_route_v6};
print " Default IPv4 route → $iface->{default_route_v4}\n" if $iface->{default_route_v4};
# Internet connectivity
print "\n${BOLD}── Internet Connectivity ──$RESET\n";
my $conn = $data->{connectivity} // {};
if ($conn->{error}) {
print " ${DIM}unavailable: $conn->{error}$RESET\n";
}
elsif (%$conn) {
for my $item (['IPv6', 'v6'], ['IPv4', 'v4']) {
my ($family_label, $family) = @$item;
my $family_probes = ref $conn->{$family} eq 'HASH' ? $conn->{$family} : {};
my @parts;
for my $entry (@CONNECTIVITY_HOSTS) {
my $host = $entry->[0];
my $result = $family_probes->{$host};
next unless ref $result eq 'HASH';
if ($result->{reachable}) {
my $ms = $result->{ms};
if (defined $ms) {
push @parts, sprintf("%s✓%s %s %s%.0fms%s", $GREEN, $RESET, $host, ms_colour($ms), $ms, $RESET);
}
else {
push @parts, sprintf("%s✓%s %s", $GREEN, $RESET, $host);
}
}
else {
push @parts, sprintf("%s✗%s %s", $RED, $RESET, $host);
}
}
my $hosts_line = @parts ? join(', ', @parts) : "${DIM}(none)$RESET";
print " $family_label: $hosts_line\n";
}
}
# Latency
print "\n${BOLD}── Latency (ICMP ping) ──$RESET\n";
print_ping_block('IPv6', $data->{ping_v6});
print_ping_block('IPv4', $data->{ping_v4});
# DNS
print "\n${BOLD}── DNS Resolution ──$RESET\n";
my $dns = $data->{dns} // {};
if ($dns->{error}) {
print " ${DIM}unavailable: $dns->{error}$RESET\n";
}
elsif (%$dns) {
print " FQDN: ", ($dns->{fqdn} // '?'), "\n";
print " Resolvers: ", join(', ', @{ $dns->{resolvers} // [] }), "\n" if @{ $dns->{resolvers} // [] };
print " ${DIM}latency not measured: no millisecond clock available$RESET\n" if $dns->{no_clock};
for my $item (['AAAA (IPv6)', 'v6'], ['A (IPv4)', 'v4']) {
my ($family_label, $family) = @$item;
my $domains = $dns->{$family} // {};
next unless %$domains;
print " ${BOLD}${family_label}:$RESET\n";
for my $entry (@CONNECTIVITY_HOSTS) {
my $domain = $entry->[0];
my $results = $domains->{$domain};
next unless ref $results eq 'ARRAY';
my @valid = grep { $_->{ms} > 0 } @$results;
my $average = @valid ? (eval { my $t = 0; $t += $_->{ms} for @valid; $t / scalar @valid }) : 0;
my $colour = ms_colour($average);
my @ips = grep { $_->{ip} ne 'N/A' } @$results;
my $ip_str = @ips ? $ips[0]{ip} : 'N/A';
if ($dns->{no_clock}) {
printf " %-25s → %s\n", $domain, $ip_str;
}
else {
printf " %-25s → %s%-18s%s %.0fms\n",
$domain, $colour, $ip_str, $RESET, $average;
}
}
}
}
# Reachability
print "\n${BOLD}── IPv6 Reachability ──$RESET\n";
print_reachability('v6', $data->{ipv6});
print "\n${BOLD}── IPv4 Reachability ──$RESET\n";
print_reachability('v4', $data->{ipv4});
# Path MTU
print "\n${BOLD}── Path MTU ──$RESET\n";
my $mtu = $data->{mtu} // {};
if (%$mtu) {
if ($mtu->{path_mtu}) {
my $colour = $mtu->{path_mtu} < 1500 ? $YELLOW : $GREEN;
print " ${colour}$mtu->{path_mtu}B$RESET (", ($mtu->{method} // ''), ")\n";
}
elsif ($mtu->{method}) {
print " ${DIM}$mtu->{method}$RESET\n";
}
elsif ($mtu->{error}) {
print " ${DIM}unavailable: $mtu->{error}$RESET\n";
}
}
# Listening ports
print "\n${BOLD}── Listening Ports ──$RESET\n";
my $ports = $data->{listening} // {};
if (%$ports) {
if ($ports->{error}) {
print " ${DIM}unavailable: $ports->{error}$RESET\n";
}
else {
my $total = 0;
$total += scalar @{ $ports->{$_} } for grep { ref $ports->{$_} eq 'ARRAY' } keys %$ports;
if ($total > 0) {
for my $version ('tcp6', 'tcp4', 'tcp46') {
for my $entry (@{ $ports->{$version} // [] }) {
printf " %s %-22s %s%s%s\n", $version, $entry->{addr}, $DIM, $entry->{process}, $RESET;
}
}
}
else {
print " (none)\n";
}
}
}
# Grade breakdown
print "\n${BOLD}── Grade Breakdown ──$RESET\n";
my $breakdown = $grade_info->{breakdown} // {};
my @labels = (
['packet_loss', 'Packet loss'],
['latency', 'Latency'],
['dns_speed', 'DNS speed'],
['ipv6_dualstack', 'IPv6 / dual-stack'],
['ipv4_reachability', 'IPv4 reachability'],
);
for my $item (@labels) {
my ($key, $label) = @$item;
my $points = $breakdown->{$key} // 0;
my $filled = int($points / 2);
my $bar_line = ("█" x $filled) . ("░" x (10 - $filled));
printf " %-20s %s %s/20\n", $label, $bar_line, $points;
}
# Footer
print "\n${BOLD}${bar}$RESET\n";
print " ${BOLD}Overall Grade: ${grade_colour}${grade_letter}$RESET ";
print "(", ($grade_info->{score} // '?'), "/", ($grade_info->{max_score} // 100), ")\n";
print "${BOLD}${bar}$RESET\n\n";
}
# ---------------------------------------------------------------------------
# Parallel collection
# ---------------------------------------------------------------------------
# The checks are independent and each is dominated by waiting, so they run as
# forked children, one per check. A child writes its result as a Perl literal
# into the private scratch directory and the parent reads it back once the child
# has been reaped, because the builtin pipe forms give no way to collect several
# results without a select loop over them. Progress lines appear in completion
# order.
sub scratch_dir {
return $TMP_DIR if defined $TMP_DIR;
# An empty TMPDIR counts as unset: an empty base would place the scratch
# directory at the filesystem root, where a root run would even succeed.
my $base = defined $ENV{TMPDIR} && length $ENV{TMPDIR} ? $ENV{TMPDIR} : '/tmp';
for my $attempt (0 .. 9) {
my $dir = "$base/network-diag.$$" . ($attempt ? ".$attempt" : '');
# mkdir is atomic and refuses to follow a symlink, so a hostile entry in a
# shared /tmp cannot redirect the writes.
if (mkdir($dir, 0700)) {
$TMP_DIR = $dir;
return $dir;
}
}
die "cannot create a scratch directory under $base\n";
}
sub remove_scratch {
return unless defined $TMP_DIR;
# Every forked child inherits the END block, so removal is restricted to the
# process that created the directory. Without this the first child to finish
# would delete the result files its siblings have yet to write.
return unless $$ == $PARENT_PID;
if (opendir(my $dh, $TMP_DIR)) {
for my $entry (readdir($dh)) {
next if $entry eq '.' || $entry eq '..';
unlink("$TMP_DIR/$entry");
}
closedir($dh);
}
rmdir($TMP_DIR);
undef $TMP_DIR;
}
# A value as Perl source, which is what the parent eval's back. Only the
# characters a single-quoted string cannot carry are escaped.
sub perl_literal {
my ($value) = @_;
return 'undef' unless defined $value;
if (ref $value eq 'HASH') {
return '{ ' . join(', ', map { perl_literal($_) . ' => ' . perl_literal($value->{$_}) }
sort keys %$value) . ' }';
}
if (ref $value eq 'ARRAY') {
return '[' . join(', ', map { perl_literal($_) } @$value) . ']';
}
return $value if !ref $value && $value =~ /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/;
my $text = $value;
$text =~ s/([\\'])/\\$1/g;
return "'$text'";
}
sub write_literal {
my ($file, $value) = @_;
open(my $fh, '>', $file) or die "cannot write the result file $file: $!\n";
print {$fh} perl_literal($value), "\n";
close($fh) or die "cannot write the result file $file: $!\n";
return 1;
}
sub read_literal {
my ($file) = @_;
return (undef, 'result file was not written') unless -f $file;
open(my $fh, '<', $file) or return (undef, "cannot read the result: $!");
local $/ = undef;
my $text = <$fh>;
close($fh);
return (undef, 'empty result') unless defined $text && length $text;
my $value = eval $text; # written by our own child, read before it is removed
return (undef, 'unreadable result') if $@;
return ($value, '');
}
sub collect_checks {
my ($data, $os_type, $target, $protocol) = @_;
my @plan = (
['connectivity', 'internet connectivity', sub { check_connectivity($protocol) }],
);
# Only the families the user asked for, IPv6 first.
if ($protocol ne 'v4') {
push @plan, ['ping_v6', 'ping (IPv6)', sub { ping_host($target, $os_type, protocol => 'v6') }];
}
if ($protocol ne 'v6') {
push @plan, ['ping_v4', 'ping (IPv4)', sub { ping_host($target, $os_type, protocol => 'v4') }];
}
push @plan,
['dns', 'DNS resolution', sub { check_dns($protocol) }],
['mtu', 'path MTU', sub { check_mtu($target, $protocol, $os_type) }],
['listening', 'listening ports', sub { listening_ports($os_type) }];
my $dir = scratch_dir();
my (%pid_of, %file_of, %label_of);
for my $item (@plan) {
my ($key, $label, $code) = @$item;
my $file = "$dir/$key";
$file_of{$key} = $file;
$label_of{$key} = $label;
my $pid = fork();
die "cannot fork: $!\n" unless defined $pid;
if ($pid == 0) {
# A child must not inherit the parent's interrupt handling, or one
# Ctrl-C would print the message once per process.
$SIG{INT} = 'DEFAULT';
$SIG{TERM} = 'DEFAULT';
my $ok = eval { write_literal($file, $code->()) };
unless ($ok) {
my $error = $@ || 'unknown error';
$error =~ s/\s+\z//;
write_literal($file, { error => $error });
}
exit($ok ? 0 : 1);
}
$pid_of{$pid} = $key;
}
my $count = scalar @plan;
print STDERR " Running $count checks in parallel...\n";
for (1 .. $count) {
my $pid = waitpid(-1, 0);
last if $pid <= 0;
my $key = $pid_of{$pid};
next unless defined $key;
my $exit_status = $? >> 8;
my ($result, $read_error) = read_literal($file_of{$key});
my $label = $label_of{$key};
if ($exit_status != 0 || !defined $result) {
my $message = $read_error;
$message = $result->{error} if ref $result eq 'HASH' && $result->{error};
$message = 'check failed' unless defined $message && length $message;
$data->{$key} = { error => $message };
print STDERR " $label... failed ($message)\n";
next;
}
$data->{$key} = $result;
if (ref $result eq 'HASH' && $result->{note}) {
print STDERR " $label... warning ($result->{note})\n";
}
else {
print STDERR " $label... done\n";
}
}
remove_scratch();
return;
}
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
sub usage {
my $name = $0;
$name =~ s{.*/}{};
return <<"USAGE";
Usage: $name [options]
Network diagnostics: latency, DNS, MTU, dual-stack, ports
Options:
--target HOST Target host for tests (default: cloudflare.com)
--protocol FAMILY Address family: auto (dual-stack), v4 (IPv4 only),
v6 (IPv6 only) (default: auto)
--version Show the version and exit
-h, --help Show this help and exit
USAGE
}
sub parse_args {
my %opt = (target => 'cloudflare.com', protocol => 'auto');
my @argv = @ARGV;
while (defined(my $arg = shift @argv)) {
if ($arg eq '--target' || $arg eq '--protocol') {
my $value = shift @argv;
if (!defined $value) {
print STDERR "argument $arg: expected one argument\n";
print STDERR usage();
exit 2;
}
$opt{ substr($arg, 2) } = $value;
}
elsif ($arg =~ /^--(target|protocol)=(.*)$/s) {
$opt{$1} = $2;
}
elsif ($arg eq '--help' || $arg eq '-h') {
print usage();
exit 0;
}
elsif ($arg eq '--version') {
my $name = $0;
$name =~ s{.*/}{};
print "$name $VERSION\n";
exit 0;
}
else {
print STDERR "unrecognised argument: $arg\n";
print STDERR usage();
exit 2;
}
}
if ($opt{protocol} ne 'auto' && $opt{protocol} ne 'v4' && $opt{protocol} ne 'v6') {
print STDERR "argument --protocol: invalid choice: '$opt{protocol}' (choose from auto, v4, v6)\n";
print STDERR usage();
exit 2;
}
return %opt;
}
sub main {
my %opt = parse_args();
my $target = $opt{target};
my $protocol = $opt{protocol};
my $os_type = detect_os();
my $hostname = check_hostname();
my $data = {
hostname => $hostname,
version => $VERSION,
os => $os_type,
protocol => $protocol,
interfaces => {},
connectivity => {},
ping => {},
ping_v4 => undef,
ping_v6 => undef,
dns => {},
ipv4 => {},
ipv6 => {},
mtu => {},
listening => {},
grade => {},
};
# Interfaces first: instant, sequential, and the reachability sections
# below depend on the result.
status('Checking interfaces');
$data->{interfaces} = interface_info($os_type);
my $iface_error = $data->{interfaces}{error};
status_done($iface_error ? "warning: $iface_error" : 'done');
collect_checks($data, $os_type, $target, $protocol);
# Per-family reachability from interface data plus the shared probes.
my $conn = $data->{connectivity} // {};
my $probes_v4 = ref $conn->{v4} eq 'HASH' ? $conn->{v4} : {};
my $probes_v6 = ref $conn->{v6} eq 'HASH' ? $conn->{v6} : {};
$data->{ipv6} = reachability_report('v6', $data->{interfaces}, $probes_v6);
$data->{ipv4} = reachability_report('v4', $data->{interfaces}, $probes_v4);
if ($protocol eq 'v4') { $data->{ipv6}{skipped} = 1 }
elsif ($protocol eq 'v6') { $data->{ipv4}{skipped} = 1 }
# The ping that summarises the run: the requested family, or IPv6 when it
# answered, which is the dual-stack preference.
if ($protocol eq 'v4') {
$data->{ping} = $data->{ping_v4} // {};
}
elsif ($protocol eq 'v6') {
$data->{ping} = $data->{ping_v6} // {};
}
else {
my $ping_v4 = $data->{ping_v4};
my $ping_v6 = $data->{ping_v6};
my $v6_received = ref $ping_v6 eq 'HASH' ? ($ping_v6->{received} // 0) : 0;
if ($v6_received > 0 && ref $ping_v6 eq 'HASH') {
$data->{ping} = $ping_v6;
}
elsif (ref $ping_v4 eq 'HASH') {
$data->{ping} = $ping_v4;
}
else {
$data->{ping} = {};
}
}
$data->{grade} = compute_grade($data);
print_report($data);
return 0;
}
$SIG{INT} = sub {
print STDERR "\nInterrupted.\n";
remove_scratch();
exit 130;
};
$SIG{TERM} = sub {
remove_scratch();
exit 143;
};
END {
remove_scratch();
}
exit(main()) unless caller;