Files
scripts/tests/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

317 lines
14 KiB
Perl

#!/usr/bin/env perl
# Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
# SPDX-License-Identifier: MIT
# Checks for network-diag.pl: the address arithmetic, the address classification, the
# ping parsing, the MTU family fallback and the small helpers.
#
# Run from anywhere: perl tests/network-diag.pl
use strict;
use warnings;
my $root = $0 =~ m{^(.*)/[^/]+$} ? "$1/.." : '..';
$root = "./$root" if $root !~ m{^/};
require "$root/network-diag.pl";
my ($passed, $failed) = (0, 0);
sub is {
my ($got, $want, $label) = @_;
$got = 'undef' unless defined $got;
$want = 'undef' unless defined $want;
if ($got eq $want) {
$passed++;
print "ok $label\n";
return 1;
}
$failed++;
print "FAIL $label\n got: $got\n want: $want\n";
return 0;
}
sub check {
my ($cond, $label) = @_;
return is($cond ? 1 : 0, 1, $label);
}
sub hex_of {
my ($bytes) = @_;
return join(' ', map { sprintf('%02x', $_) } unpack('C*', $bytes));
}
# ---------------------------------------------------------------------------
# IPv4 and IPv6 parsing
# ---------------------------------------------------------------------------
is(hex_of(ip4_bytes('127.0.0.1')), '7f 00 00 01', 'ip4: 127.0.0.1');
is(hex_of(ip4_bytes('0.0.0.0')), '00 00 00 00', 'ip4: the unspecified address');
is(hex_of(ip4_bytes('255.255.255.255')), 'ff ff ff ff', 'ip4: the broadcast address');
is(ip4_bytes('256.0.0.1'), undef, 'ip4: an octet above 255 is refused');
is(ip4_bytes('1.2.3'), undef, 'ip4: three octets are refused');
is(ip4_bytes('1.2.3.4.5'), undef, 'ip4: five octets are refused');
is(ip4_bytes('::1'), undef, 'ip4: an IPv6 address is refused');
is(hex_of(ip6_bytes('::')), '00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00',
'ip6: the unspecified address');
is(hex_of(ip6_bytes('::1')), '00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 01',
'ip6: loopback in shorthand');
is(hex_of(ip6_bytes('2001:db8::1')), '20 01 0d b8 00 00 00 00 00 00 00 00 00 00 00 01',
'ip6: shorthand in the middle');
is(hex_of(ip6_bytes('::ffff:127.0.0.1')), '00 00 00 00 00 00 00 00 00 00 ff ff 7f 00 00 01',
'ip6: an embedded IPv4 address');
is(hex_of(ip6_bytes('fe80::1%eth0')), 'fe 80 00 00 00 00 00 00 00 00 00 00 00 00 00 01',
'ip6: a scope suffix is dropped');
is(hex_of(ip6_bytes('2001:0db8:0000:0000:0000:0000:0000:0001')),
'20 01 0d b8 00 00 00 00 00 00 00 00 00 00 00 01', 'ip6: the full form');
is(ip6_bytes('127.0.0.1'), undef, 'ip6: an IPv4 address is refused');
is(ip6_bytes('2001:db8:::1'), undef, 'ip6: a malformed address is refused');
is(ip6_bytes('2001:db8::1::2'), undef, 'ip6: two shorthands are refused');
is(is_mapped6('::ffff:10.0.0.1'), 1, 'mapped: an IPv4-mapped address');
is(is_mapped6('::1'), 0, 'mapped: loopback is not mapped');
is(is_mapped6('2001:db8::1'), 0, 'mapped: a global address is not mapped');
# ---------------------------------------------------------------------------
# sockaddr packing
# ---------------------------------------------------------------------------
my $sa = sa_in('127.0.0.1', 80);
is(length($sa), 16, 'sa_in: a sockaddr_in is 16 bytes');
is(unpack('S', substr($sa, 0, 2)), 2, 'sa_in: the family is AF_INET');
is(unpack('n', substr($sa, 2, 2)), 80, 'sa_in: the port is network order');
is(hex_of(substr($sa, 4, 4)), '7f 00 00 01', 'sa_in: the address follows');
is(hex_of(substr($sa, 8)), '00 00 00 00 00 00 00 00', 'sa_in: the padding is zero');
is(sa_in('::1', 80), undef, 'sa_in: an IPv6 address is refused');
my $sa6 = sa_in6('::1', 443);
is(length($sa6), 28, 'sa_in6: a sockaddr_in6 is 28 bytes');
is(unpack('n', substr($sa6, 2, 2)), 443, 'sa_in6: the port is network order');
is(hex_of(substr($sa6, 8, 16)), '00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 01',
'sa_in6: the address follows the flow information');
is(sa_in6('127.0.0.1', 443), undef, 'sa_in6: an IPv4 address is refused');
# ---------------------------------------------------------------------------
# Address classification
# ---------------------------------------------------------------------------
is(class4(ip4_bytes('127.0.0.1')), 'loopback', 'class4: loopback');
is(class4(ip4_bytes('169.254.1.1')), 'linklocal', 'class4: link-local');
is(class4(ip4_bytes('0.0.0.0')), 'unspecified', 'class4: unspecified');
is(class4(ip4_bytes('224.0.0.1')), 'multicast', 'class4: multicast');
is(class4(ip4_bytes('10.0.0.1')), 'private', 'class4: the ten network');
is(class4(ip4_bytes('192.168.1.1')), 'private', 'class4: the 192.168 network');
is(class4(ip4_bytes('172.16.0.1')), 'private', 'class4: the 172.16 network');
is(class4(ip4_bytes('100.64.0.1')), 'private', 'class4: carrier-grade NAT');
is(class4(ip4_bytes('8.8.8.8')), 'global', 'class4: a global address');
is(class6(ip6_bytes('::')), 'unspecified', 'class6: unspecified');
is(class6(ip6_bytes('::1')), 'loopback', 'class6: loopback');
is(class6(ip6_bytes('::ffff:1.2.3.4')), 'mapped', 'class6: IPv4-mapped');
is(class6(ip6_bytes('fe80::1')), 'linklocal', 'class6: link-local');
is(class6(ip6_bytes('ff02::1')), 'multicast', 'class6: multicast');
is(class6(ip6_bytes('fd00::1')), 'private', 'class6: unique local');
is(class6(ip6_bytes('2002::1')), 'private', 'class6: 6to4');
is(class6(ip6_bytes('2001:db8::1')), 'global', 'class6: a documentation prefix');
is(address_kind('v4', '127.0.0.1'), 'loopback', 'address_kind: an IPv4 class');
is(address_kind('v6', '::1'), 'loopback', 'address_kind: an IPv6 class');
is(address_kind('v4', 'not-an-address'), '', 'address_kind: unparsable input is empty');
# ---------------------------------------------------------------------------
# Ping output
# ---------------------------------------------------------------------------
my $iputils = <<'PING';
PING cloudflare.com (104.16.132.229) 56(84) bytes of data.
64 bytes from 104.16.132.229: icmp_seq=1 ttl=57 time=11.2 ms
64 bytes from 104.16.132.229: icmp_seq=2 ttl=57 time=12.4 ms
64 bytes from 104.16.132.229: icmp_seq=3 ttl=57 time=10.1 ms
--- cloudflare.com ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2003ms
rtt min/avg/max/mdev = 10.109/11.233/12.401/0.938 ms
PING
my ($latencies, $received) = parse_ping_output($iputils);
is(scalar(@$latencies), 3, 'ping: three replies are parsed');
is($latencies->[0], '11.2', 'ping: the first latency');
is($latencies->[2], '10.1', 'ping: the last latency');
is($received, 3, 'ping: the received counter');
my $lossy = <<'PING';
2 packets transmitted, 1 received, 50% packet loss, time 1001ms
PING
my ($none, $one) = parse_ping_output($lossy);
is(scalar(@$none), 0, 'ping: a transcript without replies has no latencies');
is($one, 1, 'ping: a partial loss is counted');
my ($min, $max, $sum) = min_max_sum([3, 1, 2]);
is($min, 1, 'latency: the minimum');
is($max, 3, 'latency: the maximum');
is($sum, 6, 'latency: the sum');
# ---------------------------------------------------------------------------
# Listening sockets
# ---------------------------------------------------------------------------
my @ss_ports = parse_ss_output(
"State Recv-Q Send-Q Local Address:Port Peer Address:Port Process\n" .
"LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:((\"sshd daemon\",pid=977,fd=3))\n" .
"LISTEN 0 128 [::]:22 [::]:*\n"
);
is($ss_ports[0]{process}, 'users:(("sshd daemon",pid=977,fd=3))',
'ss: a process name with spaces is kept whole');
is($ss_ports[0]{bucket}, 'tcp4', 'ss: a plain local address is IPv4');
is($ss_ports[1]{process}, '', 'ss: a socket without permission has no process');
is($ss_ports[1]{bucket}, 'tcp6', 'ss: a bracketed local address is IPv6');
# ---------------------------------------------------------------------------
# Grading
# ---------------------------------------------------------------------------
is(grade_letter(100), 'A', 'grade: a perfect score');
is(grade_letter(90), 'A', 'grade: the A boundary');
is(grade_letter(89.9), 'B', 'grade: just under the A boundary');
is(grade_letter(80), 'B', 'grade: the B boundary');
is(grade_letter(70), 'C', 'grade: the C boundary');
is(grade_letter(60), 'D', 'grade: the D boundary');
is(grade_letter(59.9), 'F', 'grade: just under the D boundary');
is(grade_letter(0), 'F', 'grade: zero');
my $graded = compute_grade({});
is(ref($graded), 'HASH', 'grade: the verdict is a hash');
is($graded->{score}, 0, 'grade: no data scores zero');
is($graded->{grade}, 'F', 'grade: no data fails');
check($graded->{max_score} > 0, 'grade: the maximum is reported');
check(ref($graded->{breakdown}) eq 'HASH', 'grade: the breakdown is a hash');
my $full = compute_grade({
ping => { loss_pct => 0, avg_ms => 10 },
dns => { v4 => { 'example.org' => [{ ms => 5 }] } },
});
check($full->{score} >= 0 && $full->{score} <= $full->{max_score},
'grade: a measured run stays within the maximum');
is(ms_colour(undef), "\033[2m", 'colour: an unmeasured latency is dim');
is(ms_colour(0), "\033[2m", 'colour: a zero latency is dim');
check(ms_colour(5) ne ms_colour(5000), 'colour: the bands differ');
# ---------------------------------------------------------------------------
# Serialisation and arguments
# ---------------------------------------------------------------------------
is(perl_literal(5), '5', 'literal: an integer stays bare');
is(perl_literal(1.5), '1.5', 'literal: a float stays bare');
is(perl_literal('abc'), "'abc'", 'literal: a word is quoted');
is(perl_literal('42x'), "'42x'", 'literal: a number-like word is quoted');
is(perl_literal(undef), 'undef', 'literal: undef');
is(perl_literal("it's"), "'it\\'s'", 'literal: a quote is escaped');
is(perl_literal([1, 2]), '[1, 2]', 'literal: an array');
is(perl_literal({ b => 2, a => 1 }), "{ 'a' => 1, 'b' => 2 }", 'literal: a hash in key order');
eval { write_literal('/nonexistent/network-diag-check', 1) };
check(scalar($@ =~ /cannot write /), 'result write: a failed write names the operation');
{
local $ENV{TMPDIR} = '';
my $scratch = eval { scratch_dir() };
check(defined($scratch) && index($scratch // '', '/tmp/') == 0,
'scratch: an empty TMPDIR falls back to /tmp');
remove_scratch() if defined $scratch;
}
my $report_out = '';
open(my $report_cap, '>', \$report_out) or die "cannot capture the report: $!\n";
{
local *STDOUT = $report_cap;
print_report({ hostname => 'testhost', os => 'linux', dns => { error => 'probe boom' } });
}
close($report_cap);
check(index($report_out, 'unavailable: probe boom') >= 0,
'report: a failed DNS check names its error');
my @saved = @ARGV;
@ARGV = ('--target', 'example.org', '--protocol', 'v6');
my %args = parse_args();
is($args{target}, 'example.org', 'args: --target takes a value');
is($args{protocol}, 'v6', 'args: --protocol takes a value');
@ARGV = @saved;
@ARGV = ();
my %defaults = parse_args();
is($defaults{protocol}, 'auto', 'args: the family defaults to auto');
is($defaults{target}, 'cloudflare.com', 'args: the target has a default');
@ARGV = @saved;
# ---------------------------------------------------------------------------
# The MTU probe, through fixture binaries on a private PATH
# ---------------------------------------------------------------------------
# A fake getent answers the resolver probe and one fixed name, and a fake ping
# models a host whose IPv6 path is dead while IPv4 passes a 1500 B DF probe.
# Together they exercise the family fallback of check_mtu end to end without
# touching the network.
sub install_fake_bin {
my ($dir, $name, $body) = @_;
open(my $fh, '>', "$dir/$name") or die "cannot write $dir/$name: $!\n";
print {$fh} "#!/usr/bin/env perl\n", $body;
close($fh);
chmod(0755, "$dir/$name") or die "cannot chmod $dir/$name: $!\n";
return;
}
my $bindir = "/tmp/network-diag-test-bin.$$";
mkdir($bindir, 0700) or die "cannot create $bindir: $!\n";
{
local $ENV{PATH} = "$bindir:$ENV{PATH}";
install_fake_bin($bindir, 'getent', <<'FIXTURE');
my ($verb, $host) = @ARGV;
if ($verb eq 'ahostsv4' && $host eq 'localhost') {
print "127.0.0.1 STREAM\n";
print "# the localhost resolver probe\n";
exit 0;
}
if ($host eq 'mtu-fallback.test') {
print $verb eq 'ahostsv6' ? "2001:db8::1 STREAM\n" : "192.0.2.10 STREAM\n";
exit 0;
}
exit 2;
FIXTURE
install_fake_bin($bindir, 'ping', <<'FIXTURE');
my ($family, $payload) = ('', 0);
for (my $i = 0; $i < @ARGV; $i++) {
$family = $ARGV[$i] if $ARGV[$i] eq '-4' || $ARGV[$i] eq '-6';
$payload = $ARGV[$i + 1] if $ARGV[$i] eq '-s';
}
if ($family eq '-6' || $payload > 1472) {
print "1 packets transmitted, 0 received, 100% packet loss, time 0ms\n";
exit 1;
}
print "1 packets transmitted, 1 received, 0% packet loss, time 0ms\n";
exit 0;
FIXTURE
my $auto = check_mtu('mtu-fallback.test', 'auto', 'linux');
is($auto->{path_mtu}, 1500, 'mtu: a dead preferred family falls back to the other');
check(index($auto->{method} // '', 'IPv4 fallback') >= 0,
'mtu: the fallback names the family that answered');
my $v6 = check_mtu('mtu-fallback.test', 'v6', 'linux');
is($v6->{path_mtu}, 0, 'mtu: an explicit protocol choice does not fall back');
is($v6->{method}, 'ping DF probe (no probe size succeeded)',
'mtu: an explicit protocol choice reports the failure');
my $v4 = check_mtu('mtu-fallback.test', 'v4', 'linux');
is($v4->{path_mtu}, 1500, 'mtu: the preferred family answering needs no fallback');
is($v4->{method}, 'ping DF probe (1500B OK)', 'mtu: a direct hit carries no fallback note');
}
unlink(glob("$bindir/*"));
rmdir($bindir) or die "cannot remove $bindir: $!\n";
print "\n" . ($failed ? "$failed FAILED of " . ($passed + $failed) : "all $passed checks passed") . "\n";
exit($failed ? 1 : 0);