#!/usr/bin/env perl # Copyright (c) 2026 Petr Balvín (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], '= 0 || index($lines[0], '') >= 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;