Files
webmin/nftables/t/run-tests.t
Ilia Ross 065ce627a0 Add quick service and port forward controls
* Note: Add lightweight quick controls for allowed ports, services, and port forwards, with service autocomplete, ACLs, and structured NAT redirect/DNAT editing.
2026-05-16 15:55:28 +02:00

491 lines
18 KiB
Perl
Executable File

#!/usr/bin/perl
use strict;
use warnings;
use Test::More;
use Cwd qw(abs_path);
use File::Temp qw(tempdir);
sub script_dir
{
my $path = $0;
if ($path =~ m{^/}) {
$path =~ s{/[^/]+$}{};
return $path;
}
my $cwd = `pwd`;
chomp($cwd);
if ($path =~ m{/}) {
$path =~ s{/[^/]+$}{};
return $cwd.'/'.$path;
}
return $cwd;
}
my $bindir = script_dir();
my $rootdir = abs_path("$bindir/../..") or die "rootdir: $!";
my $confdir = tempdir(CLEANUP => 1);
my $vardir = tempdir(CLEANUP => 1);
open(my $cfh, ">", "$confdir/config") or die "config: $!";
print $cfh "os_type=linux\nos_version=0\n";
close($cfh);
open(my $vfh, ">", "$confdir/var-path") or die "var-path: $!";
print $vfh "$vardir\n";
close($vfh);
$ENV{'WEBMIN_CONFIG'} = $confdir;
$ENV{'WEBMIN_VAR'} = $vardir;
$ENV{'FOREIGN_MODULE_NAME'} = 'nftables';
$ENV{'FOREIGN_ROOT_DIRECTORY'} = $rootdir;
chdir("$bindir/..") or die "chdir: $!";
require "$bindir/../nftables-lib.pl";
our %access;
{
local %access = (quick => 1);
ok(check_quick_acl('forward'), 'quick sub-acl defaults to allowed');
$access{quick_forward} = 0;
ok(!check_quick_acl('forward'), 'quick sub-acl can deny one action');
$access{quick_forward} = 1;
ok(check_quick_acl('forward'), 'quick sub-acl can allow one action');
$access{quick} = 0;
ok(!check_quick_acl('forward'), 'quick master acl denies sub-actions');
}
my $services_file = "$confdir/services";
open(my $sfh, ">", $services_file) or die "services: $!";
print $sfh "customsvc 4242/tcp custom-alias\n";
print $sfh "customsvc 4243/udp\n";
close($sfh);
is(get_etc_service_port('custom-alias', 'tcp', $services_file), 4242,
'custom services alias lookup');
is(get_etc_service_port('customsvc', 'udp', $services_file), 4243,
'custom services udp lookup');
my $sshd_config = "$confdir/sshd_config";
open(my $sshcfh, ">", $sshd_config) or die "sshd_config: $!";
print $sshcfh "Port 2223\n";
print $sshcfh "Port 2200\n";
print $sshcfh "ListenAddress 0.0.0.0:2022\n";
close($sshcfh);
mkdir "$confdir/sshd" or die "sshd confdir: $!";
open(my $sshmodfh, ">", "$confdir/sshd/config") or die "sshd module config: $!";
print $sshmodfh "sshd_path=/bin/true\n";
print $sshmodfh "sshd_config=$sshd_config\n";
close($sshmodfh);
sub check_fields
{
my ($name, $got, $expect) = @_;
foreach my $k (sort keys %$expect) {
is($got->{$k}, $expect->{$k}, "$name $k");
}
}
sub write_ruleset
{
my ($dir, $name, $content) = @_;
my $file = "$dir/$name";
open(my $fh, ">", $file) or die "$name: $!";
print $fh $content;
close($fh);
return $file;
}
my @cases = (
{
name => 'tcp dport accept',
line => 'tcp dport 22 accept',
expect => { proto => 'tcp', dport => '22', action => 'accept' },
},
{
name => 'iif oif drop',
line => 'iif "eth0" oif "eth1" drop',
expect => { iif => 'eth0', oif => 'eth1', action => 'drop' },
},
{
name => 'comment with quotes',
line => 'tcp dport 80 accept comment "a \\"quote\\""',
expect => { proto => 'tcp', dport => '80', action => 'accept', comment => 'a "quote"' },
},
{
name => 'ct state',
line => 'ct state established,related accept',
expect => { ct_state => 'established,related', action => 'accept' },
},
{
name => 'icmp type',
line => 'icmp type echo-request accept',
expect => { icmp_type => 'echo-request', action => 'accept' },
},
{
name => 'limit log counter',
line => 'tcp dport 22 limit rate 10/second burst 20 packets log prefix "ssh" level info counter accept',
expect => {
proto => 'tcp',
dport => '22',
limit_rate => '10/second',
limit_burst => '20',
log_prefix => 'ssh',
log_level => 'info',
counter => 1,
action => 'accept',
},
},
{
name => 'unknown tokens preserved',
line => 'tcp dport 22 meta skgid 1000 accept',
expect => { proto => 'tcp', dport => '22', action => 'accept' },
preserve => 'meta skgid 1000',
},
{
name => 'redirect target',
line => 'tcp dport 2023 redirect to :20022 comment "Webmin quick forward"',
expect => {
proto => 'tcp',
dport => '2023',
action => 'redirect',
nat_port => '20022',
comment => 'Webmin quick forward',
},
},
{
name => 'dnat target',
line => 'tcp dport 8080 dnat ip to 192.0.2.10:80 comment "Webmin quick forward"',
expect => {
proto => 'tcp',
dport => '8080',
action => 'dnat',
nat_family => 'ip',
nat_addr => '192.0.2.10',
nat_port => '80',
comment => 'Webmin quick forward',
},
},
);
foreach my $c (@cases) {
my $r = parse_rule_text($c->{line});
ok($r && ref($r) eq 'HASH', "$c->{name} parse hash");
check_fields($c->{name}, $r, $c->{expect});
my $out = format_rule_text($r);
ok($out =~ /\S/, "$c->{name} formatted non-empty");
if ($c->{preserve}) {
like($out, qr/\Q$c->{preserve}\E/, "$c->{name} preserves unknowns");
}
my $r2 = parse_rule_text($out);
check_fields($c->{name}.' roundtrip', $r2, $c->{expect});
}
my $redirect_desc = describe_rule(parse_rule_text(
'tcp dport 2026 redirect to :20026 comment "Webmin quick forward"'));
like($redirect_desc, qr/Redirect.*:20026.*Destination port 2026/,
'redirect rule summary includes target port');
my $dnat_desc = describe_rule(parse_rule_text(
'tcp dport 2024 dnat ip to 10.211.55.21:20024 comment "Webmin quick forward"'));
like($dnat_desc, qr/DNAT.*10\.211\.55\.21:20024.*Destination port 2024/,
'dnat rule summary includes target address and port');
is(format_forward_target({ family => 'ip' }, '10.211.55.21', 'ip', '20024'),
'dnat to 10.211.55.21:20024',
'ip quick forward omits inet-only dnat family');
my $ruleset = write_ruleset($confdir, "basic.nft", <<'EOF');
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
iif "lo" accept
ip saddr 192.168.1.0/24 tcp dport 22 accept comment "ssh"
ct state established,related accept
}
}
EOF
my @tables = get_nftables_save($ruleset);
ok(@tables == 1, 'ruleset table count');
my $t = $tables[0];
is($t->{family}, 'inet', 'ruleset family');
is($t->{name}, 'filter', 'ruleset name');
my $chain = $t->{chains}->{input};
ok($chain, 'input chain present');
is($chain->{type}, 'filter', 'chain type');
is($chain->{hook}, 'input', 'chain hook');
is($chain->{priority}, '0', 'chain priority');
is($chain->{policy}, 'drop', 'chain policy');
my $ruleset_prio = write_ruleset($confdir, "externally-managed-priority.nft", <<'EOF');
table inet externally_managed {
flags owner,persist
chain managed_INPUT {
type filter hook input priority filter + 10; policy accept;
ct state { established, related } accept
}
}
EOF
my @tables_prio = get_nftables_save($ruleset_prio);
ok(@tables_prio == 1, 'externally managed priority table count');
is($tables_prio[0]->{flags}, 'owner,persist', 'externally managed table flags');
ok(table_is_externally_managed($tables_prio[0]),
'table with owner,persist flags is externally managed');
is(active_table_status($tables_prio[0], []), 'external',
'external active table status');
is(active_table_status({ family => 'inet', name => 'filter' }, [ $t ]), 'webmin',
'saved active table status');
is(active_table_status({ family => 'inet', name => 'loose' }, []), 'unclaimed',
'unclaimed active table status');
my $managed_chain = $tables_prio[0]->{chains}->{managed_INPUT};
ok($managed_chain, 'externally managed priority chain present');
is($managed_chain->{type}, 'filter', 'externally managed priority chain type');
is($managed_chain->{hook}, 'input', 'externally managed priority chain hook');
is($managed_chain->{priority}, 'filter + 10',
'externally managed symbolic priority preserved');
is($managed_chain->{policy}, 'accept',
'externally managed priority chain policy');
is(scalar @{$tables_prio[0]->{rules}}, 1,
'externally managed chain definition is not parsed as a rule');
my @rules = @{$t->{rules}};
check_fields('ruleset r1', $rules[0], { iif => 'lo', action => 'accept' });
check_fields('ruleset r2', $rules[1], { saddr => '192.168.1.0/24', proto => 'tcp', dport => '22', action => 'accept', comment => 'ssh' });
check_fields('ruleset r3', $rules[2], { ct_state => 'established,related', action => 'accept' });
my $ruleset_sets = write_ruleset($confdir, "sets.nft", <<'EOF');
table inet filter {
set trusted_v4 {
type ipv4_addr;
flags interval;
elements = { 192.168.1.0/24, 10.0.0.1 }
}
set web_ports {
type inet_service;
elements = {
80,
443
}
}
chain input {
type filter hook input priority 0; policy drop;
ip saddr @trusted_v4 tcp dport @web_ports accept
}
}
EOF
my @tables_sets = get_nftables_save($ruleset_sets);
ok(@tables_sets == 1, 'sets ruleset table count');
my $ts = $tables_sets[0];
ok($ts->{sets} && $ts->{sets}->{trusted_v4}, 'trusted_v4 set present');
is($ts->{sets}->{trusted_v4}->{type}, 'ipv4_addr', 'trusted_v4 type');
is($ts->{sets}->{trusted_v4}->{flags}, 'interval', 'trusted_v4 flags');
is_deeply($ts->{sets}->{trusted_v4}->{elements},
[ '192.168.1.0/24', '10.0.0.1' ],
'trusted_v4 elements');
ok($ts->{sets}->{web_ports}, 'web_ports set present');
is($ts->{sets}->{web_ports}->{type}, 'inet_service', 'web_ports type');
is_deeply($ts->{sets}->{web_ports}->{elements},
[ '80', '443' ],
'web_ports elements');
my $rset = $ts->{rules}->[0];
check_fields('set rule', $rset,
{ saddr => '@trusted_v4', proto => 'tcp', dport => '@web_ports', action => 'accept' });
my $rset_out = format_rule_text($rset);
like($rset_out, qr/\@trusted_v4/, 'set rule format preserves address set');
like($rset_out, qr/\@web_ports/, 'set rule format preserves port set');
ok(validate_chain_base('filter', 'input', '0', 'accept'),
'chain base allows zero priority');
ok(!validate_chain_base('filter', 'input', undef, 'accept'),
'chain base missing priority invalid');
ok(validate_chain_base(undef, undef, undef, undef),
'chain base none set valid');
my $table_move = {
rules => [
{ chain => 'input', index => 0, text => 'r0' },
{ chain => 'input', index => 1, text => 'r1' },
{ chain => 'forward', index => 2, text => 'r2' },
{ chain => 'input', index => 3, text => 'r3' },
],
};
ok(move_rule_in_chain($table_move, 'input', 1, 'down'),
'move rule down returns true');
is($table_move->{rules}->[1]->{text}, 'r3', 'rule moved down in array');
is($table_move->{rules}->[3]->{text}, 'r1', 'rule swapped down in array');
is($table_move->{rules}->[1]->{index}, 1, 'moved rule index updated');
is($table_move->{rules}->[3]->{index}, 3, 'swapped rule index updated');
my $table_move2 = {
rules => [
{ chain => 'input', index => 0, text => 'r0' },
{ chain => 'input', index => 1, text => 'r1' },
],
};
is(move_rule_in_chain($table_move2, 'input', 0, 'up'), 0,
'top rule cannot move up');
my $quick_table = {
family => 'inet',
name => 'quick',
chains => {
input => { hook => 'input' },
},
rules => [
{ chain => 'input', index => 0, text => 'ct state established,related accept' },
],
};
is(add_quick_ip_rule($quick_table, '192.0.2.1', 'block'), undef,
'quick block rule added');
is($quick_table->{rules}->[0]->{text},
'ip saddr 192.0.2.1 drop comment "Webmin quick block"',
'quick block inserted before normal input rules');
is(add_quick_ip_rule($quick_table, '192.0.2.1', 'allow'), undef,
'quick allow rule added');
is($quick_table->{rules}->[0]->{text},
'ip saddr 192.0.2.1 accept comment "Webmin quick allow"',
'quick allow inserted before quick block rules');
like(add_quick_ip_rule($quick_table, '192.0.2.1', 'allow'), qr/exists/,
'duplicate quick rule rejected');
my $quick_ip_table = {
family => 'ip',
name => 'quick',
chains => { input => { hook => 'input' } },
rules => [ ],
};
like(add_quick_ip_rule($quick_ip_table, '2001:db8::1/64', 'allow'),
qr/cannot contain/, 'wrong address family rejected');
my $quick_port_table = {
family => 'inet',
name => 'quickports',
chains => {
input => { hook => 'input' },
},
sets => {
allowed_tcp => {
name => 'allowed_tcp',
type => 'inet_service',
elements => [ '22' ],
raw_lines => [ ],
},
},
rules => [
{
chain => 'input',
index => 0,
proto => 'tcp',
dport => '@allowed_tcp',
action => 'accept',
text => 'tcp dport @allowed_tcp accept',
},
],
};
is(add_quick_port_rule($quick_port_table, '8443', 'tcp'), undef,
'quick port added to accepted set');
is_deeply($quick_port_table->{sets}->{allowed_tcp}->{elements},
[ '22', '8443' ], 'quick port set extended');
like(add_quick_port_rule($quick_port_table, '8443', 'tcp'), qr/exists/,
'duplicate quick port rejected');
my ($customsvc) = grep { $_->{id} eq 'customsvc' }
read_etc_service_defs($services_file);
ok($customsvc, '/etc/services service parsed');
is($customsvc->{id}, 'customsvc', '/etc/services service id');
like($customsvc->{label}, qr/customsvc \(4242 TCP; 4243 UDP\)/,
'/etc/services service label includes ports and protocol');
is_deeply([ sort { $a cmp $b } quick_service_rules($customsvc) ],
[ 'tcp dport 4242 accept',
'udp dport 4243 accept' ],
'/etc/services service rules generated');
my @service_matches = search_quick_services('customsvc', 5, $services_file);
ok(@service_matches, 'quick service search returns matches');
is($service_matches[0]->{id}, 'customsvc',
'quick service search ranks exact service IDs first');
my @alias_service_matches = search_quick_services('custom-alias', 5, $services_file);
is($alias_service_matches[0]->{id}, 'customsvc',
'quick service search matches /etc/services aliases');
is(quick_service_by_id('custom-alias', $services_file)->{id}, 'customsvc',
'quick service lookup accepts /etc/services aliases');
my @empty_service_matches = search_quick_services('', 5, $services_file);
is(scalar(@empty_service_matches), 0,
'empty quick service search returns no matches');
my $forward_table = {
family => 'inet',
name => 'forward',
chains => {
input => { type => 'filter', hook => 'input', priority => 0, policy => 'drop' },
forward => { type => 'filter', hook => 'forward', priority => 0, policy => 'drop' },
},
sets => {},
rules => [],
};
is(add_quick_forward_rule($forward_table, '8080', 'tcp', '80', '192.0.2.10'), undef,
'quick forward added');
ok($forward_table->{chains}->{prerouting}, 'quick forward created prerouting chain');
is($forward_table->{chains}->{prerouting}->{type}, 'nat',
'quick forward prerouting chain is nat');
ok(scalar(grep {
$_->{chain} eq 'prerouting' &&
$_->{text} eq 'tcp dport 8080 dnat ip to 192.0.2.10:80 comment "Webmin quick forward"'
} @{$forward_table->{rules}}),
'quick forward DNAT rule added');
ok(scalar(grep {
$_->{chain} eq 'forward' &&
$_->{text} eq 'ct state established,related accept comment "Webmin quick forward"'
} @{$forward_table->{rules}}),
'quick forward established rule added');
ok(scalar(grep {
$_->{chain} eq 'forward' &&
$_->{text} eq 'ip daddr 192.0.2.10 tcp dport 80 accept comment "Webmin quick forward"'
} @{$forward_table->{rules}}),
'quick forward destination accept added');
my $redirect_table = {
family => 'inet',
name => 'redirect',
chains => {
input => { type => 'filter', hook => 'input', priority => 0, policy => 'drop' },
forward => { type => 'filter', hook => 'forward', priority => 0, policy => 'drop' },
},
sets => {},
rules => [],
};
is(add_quick_forward_rule($redirect_table, '2023', 'tcp', '20022', ''), undef,
'quick local redirect added');
my ($redirect_rule) = grep {
$_->{chain} eq 'prerouting' &&
$_->{text} eq 'tcp dport 2023 redirect to :20022 comment "Webmin quick forward"'
} @{$redirect_table->{rules}};
ok($redirect_rule, 'quick local redirect rule added');
check_fields('quick local redirect rule', $redirect_rule,
{ proto => 'tcp', dport => '2023', action => 'redirect', nat_port => '20022' });
my %setup_services = map { $_->{id} => $_ } setup_services();
is($setup_services{ssh}->{port}, '2022, 2200, 2223',
'ssh service uses configured sshd ports');
ok(scalar(grep { $_ eq 'tcp dport 2022 accept' }
@{$setup_services{ssh}->{rules}}),
'ssh service includes ListenAddress port');
my $profile_table = create_profile_ruleset('profile_virtualmin', 'virtualmin', '*');
is($profile_table->{family}, 'inet', 'profile helper family');
is($profile_table->{name}, 'profile_virtualmin', 'profile helper table name');
ok($profile_table->{sets}->{profile_hosting_tcp_ports},
'profile helper tcp port set');
is($profile_table->{sets}->{profile_hosting_tcp_ports}->{flags}, 'interval',
'profile helper tcp port set interval flag');
is_deeply($profile_table->{sets}->{profile_hosting_udp_ports}->{elements},
[ '53' ], 'profile helper udp port set elements');
ok(scalar(grep { $_->{text} eq 'tcp dport @profile_hosting_tcp_ports accept' }
@{$profile_table->{rules}}),
'profile helper tcp set rule');
ok(scalar(grep { $_->{text} eq 'ip6 daddr fe80::/64 udp dport 546 accept' }
@{$profile_table->{rules}}),
'profile helper special dhcpv6 rule');
ok(scalar(grep { $_ eq '2022' }
@{$profile_table->{sets}->{profile_hosting_tcp_ports}->{elements}}),
'profile helper includes dynamic ssh port');
done_testing();