Files
scripts/network-diag.pl
T
petrbalvin 788cf0571f
Deploy / deploy (push) Successful in 17s
Test / test (push) Successful in 53s
feat: initial release of the scripts collection
Assisted-by: GLM 5.3 Flash
2026-09-10 04:00:00 +00:00

1585 lines
56 KiB
Perl
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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;