Merge pull request #2835 from webmin/dev/nftables-remove-meta-config-layer
Some checks failed
Tests / prove (push) Has been cancelled
Package and upload artifacts / build (push) Has been cancelled

Cleanup meta-confg layer
This commit is contained in:
Ilia Ross
2026-09-13 01:35:52 +02:00
committed by GitHub
23 changed files with 1714 additions and 355 deletions

View File

@@ -36,7 +36,7 @@ print ui_table_row(
foreach my $a (
qw(view active create setup chains sets rules raw delete
apply bootup import clear quick quick_ip quick_port
apply bootup service import clear quick quick_ip quick_port
quick_service quick_forward manual)
)
{
@@ -61,7 +61,7 @@ else {
}
foreach my $a (
qw(view active create setup chains sets rules raw delete
apply bootup import clear quick quick_ip quick_port
apply bootup service import clear quick quick_ip quick_port
quick_service quick_forward manual)
)
{

View File

@@ -45,7 +45,7 @@ else {
my $flags = $t->{'flags'} || "-";
my $status_key = active_table_status($t, \@saved_tables);
my $status = $text{'active_'.$status_key};
my $is_saved = table_is_webmin_managed($t, \@saved_tables);
my $is_saved = table_is_saved($t, \@saved_tables);
my $table_url =
"active_table.cgi?family=".
urlize($t->{'family'}).

View File

@@ -24,7 +24,7 @@ $table || error($text{'active_table_notable'});
assert_table_acl($table);
my @saved_tables = get_nftables_save();
my $status_key = active_table_status($table, \@saved_tables);
my $is_saved = table_is_webmin_managed($table, \@saved_tables);
my $is_saved = table_is_saved($table, \@saved_tables);
ui_print_header(undef, $text{'active_table_title'}, "");
@@ -32,6 +32,10 @@ print ui_table_start($text{'active_table_summary'}, "width=100%", 2);
print ui_table_row($text{'active_table'}, html_escape(nft_table_spec($table)));
print ui_table_row($text{'active_flags'}, html_escape($table->{'flags'} || "-"));
print ui_table_row($text{'active_status'}, $text{'active_'.$status_key});
my ($saved_copy) = grep { table_key($_) eq table_key($table) } @saved_tables;
print ui_table_row($text{'active_file'},
"<tt>".html_escape($saved_copy->{'file'})."</tt>")
if ($saved_copy && $saved_copy->{'file'});
print ui_table_end();
if (!$is_saved && check_acl('import')) {

View File

@@ -1,25 +0,0 @@
#!/usr/bin/perl
# apply-boot.pl
# Apply Webmin-managed nftables rules from the saved configuration
use strict;
use warnings;
no warnings 'redefine';
no warnings 'uninitialized';
our ($module_name, $no_acl_check);
$no_acl_check++;
if ($0 =~ /^(.*\/)[^\/]+$/) {
chdir($1);
}
require './nftables-lib.pl'; ## no critic (Modules::RequireBarewordIncludes)
if ($module_name ne 'nftables') {
print STDERR "Command must be run with full path\n";
exit(5);
}
my $err = apply_restore();
if ($err) {
print STDERR $err, "\n";
exit(1);
}
exit(0);

View File

@@ -1,6 +1,6 @@
#!/usr/bin/perl
# bootup.cgi
# Enable or disable Webmin-managed nftables rules at boot time
# Enable or disable the system nftables service at boot time
require './nftables-lib.pl'; ## no critic
use strict;
@@ -9,12 +9,13 @@ our (%in, %text);
ReadParse();
assert_acl('bootup');
foreign_check("init") || error($text{'bootup_einit'});
nftables_service_status() || error($text{'bootup_eservice'});
if ($in{'boot'}) {
create_nftables_init();
enable_nftables_at_boot();
}
else {
disable_nftables_init();
disable_nftables_at_boot();
}
webmin_log($in{'boot'} ? "bootup" : "bootdown");
redirect("index.cgi");

View File

@@ -10,6 +10,7 @@ raw=1
delete=1
apply=1
bootup=1
service=1
import=1
clear=1
quick=1

View File

@@ -35,7 +35,7 @@ my @hook_opts = (
);
my @policy_opts = (
["", $text{'chain_policy_none'}],
map { [$_, $_] } qw(accept drop reject return queue continue)
map { [$_, $_] } qw(accept drop)
);
print ui_form_start("save_chain.cgi");

View File

@@ -1,3 +1,3 @@
<header>Policy</header>
<p>Default action for this base chain, such as <tt>accept</tt>, <tt>drop</tt>, <tt>reject</tt>, <tt>queue</tt>, or <tt>continue</tt>.</p>
<p>Optional default action for this base chain, such as <tt>accept</tt> or <tt>drop</tt>. If no policy is set, nftables uses <tt>accept</tt>.</p>
<footer>nft(8)</footer>

View File

@@ -1,4 +1,5 @@
<header>Introduction</header>
<p>nftables stores firewall rules in tables. Each table belongs to a family (such as <tt>inet</tt>, <tt>ip</tt>, or <tt>ip6</tt>) and contains one or more chains. Chains contain rules, and each rule is a sequence of tests (matches) followed by an action like <tt>accept</tt>, <tt>drop</tt>, <tt>jump</tt>, or <tt>log</tt>. Named sets can group addresses or services for reuse in multiple rules.</p>
<p>Rules are stored in the system's nftables configuration file &mdash; <tt>/etc/nftables.conf</tt> or <tt>/etc/sysconfig/nftables.conf</tt>, whichever the <tt>nftables</tt> service loads on this system. Top-level comments, <tt>define</tt> variables and <tt>include</tt> lines remain in their original positions. A table changed through Webmin may be rewritten in a normalized form. The <tt>nftables</tt> service loads that file at boot, so rules saved here survive a reboot in the same way as rules written by hand.</p>
<p>To get started, use the Setup page to create a default ruleset, or create a table and chain manually. Then add rules (and sets) from the table view. When you are ready to activate your changes, click Apply Configuration to load the ruleset into the kernel.</p>
<footer>nft(8)</footer>

View File

@@ -1,6 +1,6 @@
#!/usr/bin/perl
# import_table.cgi
# Import an active nftables table as a Webmin-managed saved table
# Import an active nftables table into the saved configuration
require './nftables-lib.pl'; ## no critic
use strict;
@@ -25,7 +25,7 @@ $source || error($text{'import_esource'});
assert_table_acl($source);
my @tables = get_nftables_save();
if (table_is_webmin_managed($source, \@tables)) {
if (table_is_saved($source, \@tables)) {
error(text('import_emanaged', nft_table_spec($source)));
}
@@ -49,17 +49,12 @@ if ($in{'import'}) {
my $import = dclone($source);
$import->{'name'} = $name;
delete($import->{'flags'});
# It came from the live ruleset, so it has no file of its own yet
delete($import->{'file'});
assert_table_acl($import);
push(@tables, $import);
write_configuration(@tables);
register_managed_table(
$import,
'source' => 'imported',
'imported_from' => nft_table_spec($source),
'imported_from_family' => $source->{'family'},
'imported_from_name' => $source->{'name'},
'imported_at' => time()
);
webmin_log("import", "table", $source->{'name'},
{'family' => $source->{'family'}, 'new' => $name});
redirect("index.cgi?table_family=".

View File

@@ -423,6 +423,15 @@ else {
# Identify current table
my $curr = $tables[$in{'table'}];
# Say which file holds it, as the ruleset can be spread over the main
# configuration file and the files it includes
if ($curr && $curr->{'file'} && !$partial) {
print ui_tag('div',
text('index_table_file',
"<tt>".html_escape($curr->{'file'})."</tt>"),
{'class' => 'nftables_table_file'}), "\n";
}
if ($curr) {
my ($sets_html, $chains_html);
@@ -845,13 +854,11 @@ if ($partial) {
print $rules_html;
my $init_support = foreign_check("init") && check_acl('bootup') ? 1 : 0;
if (
@tables &&
(check_acl('active') ||
check_acl('setup') ||
check_manual_acl() ||
$init_support)
check_manual_acl())
)
{
print ui_hr();
@@ -871,10 +878,35 @@ if (
$text{'index_edit_manual'},
$text{'index_edit_manualdesc'}
) if (check_manual_acl());
print ui_buttons_end();
}
# Service controls, shown whether or not any rules are saved yet. Without
# them there is no way to see that the service is stopped or disabled, which
# is the normal state on a system that has never had a ruleset
my $svc = foreign_check("init") ? nftables_service_status() : 0;
my $can_service = $svc && check_acl('service');
my $can_bootup = $svc && check_acl('bootup');
if ($can_service || $can_bootup) {
print ui_hr();
print ui_buttons_start();
if ($can_service) {
if (nftables_service_running()) {
my $desc = $text{'index_stopdesc'};
$desc .= " ".$text{'index_stopflush'}
if (nftables_service_stop_flushes());
print ui_buttons_row("stop.cgi", $text{'index_stop'},
$desc);
}
else {
print ui_buttons_row("start.cgi", $text{'index_start'},
$text{'index_startdesc'});
}
}
print ui_buttons_row("bootup.cgi", $text{'index_bootup'},
$text{'index_bootupdesc'},
undef, ui_yesno_radio("boot", nftables_started_at_boot()))
if ($init_support);
if ($can_bootup);
print ui_buttons_end();
}

View File

@@ -15,10 +15,5 @@ sub is_installed
{
my ($mode) = @_;
return 0 if (&check_nftables());
if ($mode) {
my $file = nftables_rules_file();
return 1 if (!-s $file);
return 2;
}
return 1;
return $mode ? 2 : 1;
}

View File

@@ -52,10 +52,10 @@ index_cmovesel=Move Selected
index_radd=Add Rule
index_apply_changes=Apply Changes
index_ruleset_active=View Active Ruleset
index_ruleset_activedesc=View active nftables tables and import copies into module's saved configuration.
index_ruleset_activedesc=View active nftables tables and import copies into the system nftables configuration.
index_foreign_firewalls=The other configured firewall modules were detected: $1. It is recommended not to use multiple programs to change the active kernel firewall configuration at the same time.
index_edit_manual=Edit Config Files
index_edit_manualdesc=Edit saved nftables configuration files manually.
index_edit_manualdesc=Edit the system nftables configuration files manually.
index_allowip_go=Allow IP/CIDR
index_blockip_go=Block IP/CIDR
index_allowport_go=Add allowed port
@@ -93,9 +93,17 @@ quick_edup=An equivalent quick rule for $1 already exists.
quick_failed=Failed to save and apply quick rule: $1
index_unapply=Revert Configuration
index_unapplydesc=Click this button to reset the configuration listed above to the one that is currently active.
index_start=Start Firewall
index_startdesc=Load the saved nftables configuration into the kernel by starting the nftables service.
index_stop=Stop Firewall
index_stopdesc=Stop the nftables service.
index_stopflush=On this system stopping the service flushes the entire kernel ruleset, including tables belonging to other software.
start_err=Failed to start the nftables service
stop_err=Failed to stop the nftables service
index_bootup=Activate at Boot
index_bootupdesc=Change whether saved module-managed nftables rules are applied at boot time or not.
index_bootupdesc=Change whether the system nftables service loads the saved rules at boot time or not.
bootup_einit=This system does not support boot-time service management from Webmin.
bootup_eservice=The nftables service does not exist on this system, so it cannot be enabled at boot.
index_return=rules list
edit_title=Edit Rule
edit_header=Rule Details
@@ -104,9 +112,12 @@ create=Create
save=Save
delete=Delete
save_err=Failed to save rule
save_eparse=Cannot safely update $1 because a table in that file has no recognizable closing brace.
apply_err=Failed to apply configuration
apply_enone=No saved nftables tables were found to apply.
apply_enone=No nftables tables were found in $1 to apply.
apply_eexternal=Cannot apply configuration because table $1 is currently marked as externally managed.
apply_einclude_loop=Cannot apply the configuration because its include files form a loop at $1.
migrate_evalidate=Cannot migrate the deprecated Webmin rules because the resulting system nftables configuration is invalid: $1
apply_esettype=Set $1 in table $2 has type $3, but chain $4 uses it for $5. Use ipv4_addr or ipv6_addr sets for address fields, and inet_service sets for port fields.
setup_header=Ruleset profile
setup_err=Failed to create ruleset profile
@@ -158,11 +169,12 @@ setup_port_usermin=Usermin
setup_port_passive_ftp=FTP passive range
setup_create=Create
setup_invalid_type=Invalid ruleset type selected.
setup_edup=Table $1 already exists in module's saved nftables configuration.
setup_edup=Table $1 already exists in the system nftables configuration.
setup_eservice=Invalid service selected: $1
setup_failed=Failed to create ruleset profile: <pre>$1</pre>
index_profile_setup=Create Ruleset Profile
index_profile_setupdesc=Create a managed nftables table from a predefined profile.
index_table_file=Defined in $1
index_table_create=Create Table
index_table_createdesc=Add a new nftables table.
index_table_delete=Delete Table
@@ -259,7 +271,8 @@ chain_ename=Chain name is invalid
chain_edup=A chain with that name already exists
chain_notable=No such table selected
chain_nochain=No such chain selected
chain_ebase=Base chains require type, hook, priority, and policy.
chain_ebase=Base chains require type, hook, and priority. Policy is optional.
chain_epolicy=Base chain policy must be accept or drop.
delete_chain_err=Failed to delete chain
delete_chain_failed=Failed to delete chain: <pre>$1</pre>
delete_chain_inuse=Chain $1 is referenced by $2 rule(s) via jump/goto. Remove those rules first.
@@ -334,11 +347,12 @@ active_chains=Chains
active_sets=Sets
active_rules=Rules
active_status=Status
active_webmin=Managed by Webmin
active_file=Saved in
active_saved=Saved in configuration
active_external=Externally managed
active_unclaimed=Unclaimed
active_unsaved=Not saved
active_import=Import Copy
active_importdesc=Import this active table as a separate module-managed table.
active_importdesc=Import this active table into the system nftables configuration.
active_clear=Clear
active_clear_all=Clear All
active_clear_alldesc=Remove all clearable active nftables tables from the live ruleset.
@@ -349,21 +363,21 @@ active_table_notable=No such active table selected
active_table_summary=Active table details
clear_title=Clear active table
clear_err=Failed to clear active table
clear_confirm=Clear active table $1 from the live ruleset? This does not change module's saved configuration.
clear_confirm=Clear active table $1 from the live ruleset? This does not change the saved configuration.
clear_failed=Failed to clear active table: $1
clear_all_title=Clear active tables
clear_all_err=Failed to clear active tables
clear_all_enone=No clearable active tables were found.
clear_all_confirm=Clear $1 active table(s) from the live ruleset? This does not change module's saved configuration. Tables managed externally will not be cleared.
clear_all_confirm=Clear $1 active table(s) from the live ruleset? This does not change the saved configuration. Tables managed externally will not be cleared.
import_title=Import active table
import_header=Import table as module-managed copy
import_header=Import table into the saved configuration
import_err=Failed to import active table
import_esource=No such active table selected
import_eexternal=Cannot import as table $1 because that active table is externally managed.
import_emanaged=Table $1 is already managed by Webmin.
import_emanaged=Table $1 is already in the saved configuration.
import_source=Source table
import_flags=Source flags
import_external_note=This active table is marked as externally managed; importing creates a separate module-managed copy and does not change the active source table
import_external_note=This active table is marked as externally managed; importing creates a separate saved copy and does not change the active source table. Tables that are actively managed by other services, such as fail2ban, should generally not be imported.
import_new_name=New table name
import_ok=Import Copy
manual_editsel=Edit nftables configuration file
@@ -390,6 +404,7 @@ acl_raw=Edit raw rule text
acl_delete=Delete tables, chains and sets
acl_apply=Apply saved configuration
acl_bootup=Enable firewall at boot
acl_service=Start and stop the firewall service
acl_import=Import active tables
acl_clear=Clear active tables
acl_quick=Use quick controls

View File

@@ -334,9 +334,7 @@ active_chains=Zincirler
active_sets=Kümeler
active_rules=Kurallar
active_status=Durum
active_webmin=Webmin tarafından yönetiliyor
active_external=Dışarıdan yönetiliyor
active_unclaimed=Sahipsiz
active_import=Kopyayı İçe Aktar
active_importdesc=Bu etkin tabloyu ayrı bir modül tarafından yönetilen tablo olarak içe aktar.
active_clear=Temizle

1
nftables/negativeacl Normal file
View File

@@ -0,0 +1 @@
service=0

File diff suppressed because it is too large Load Diff

39
nftables/postinstall.pl Normal file
View File

@@ -0,0 +1,39 @@
# postinstall.pl
use strict;
use warnings;
no warnings 'redefine';
no warnings 'once';
no warnings 'uninitialized';
require 'nftables-lib.pl'; ## no critic
# module_install()
# Migrates private rules into the system configuration and removes the
# obsolete boot action
sub module_install
{
my ($moved, $removed);
eval {
local $main::error_must_die = 1;
$moved = &migrate_legacy_nftables_config();
$removed = &remove_legacy_nftables_init();
&remove_legacy_managed_metadata();
};
if ($@) {
# Remove the browser-only wrapper before writing the package error
my $err = $@;
$err =~ s/<\/?pre>//g;
$err =~ s/\s+$//;
print STDERR "Failed to migrate nftables configuration: $err\n";
return;
}
if ($moved) {
print STDERR "Moved $moved nftables table(s) into ".
&nftables_rules_file()."\n";
}
if ($removed && !&nftables_started_at_boot()) {
print STDERR "The webmin-nftables boot action was removed, but the ".
"nftables service is not enabled at boot. Saved rules ".
"will not be loaded until it is.\n";
}
}

View File

@@ -92,6 +92,9 @@ $hook = undef if (!defined($hook) || $hook eq '');
$priority = undef if (!defined($priority) || $priority eq '');
$policy = undef if (!defined($policy) || $policy eq '');
defined($policy) && $policy !~ /^(accept|drop)$/ &&
error($text{'chain_epolicy'});
validate_chain_base($type, $hook, $priority, $policy) ||
error($text{'chain_ebase'});

View File

@@ -1,6 +1,6 @@
#!/usr/bin/perl
# save_manual.cgi
# Save the manually edited nftables rules file
# Save a manually edited nftables configuration file
require './nftables-lib.pl'; ## no critic
use strict;
@@ -16,15 +16,28 @@ my $file = $in{'file'};
indexof($file, @files) >= 0 || error($text{'manual_efile'});
$in{'data'} =~ s/\r//g;
my $err = validate_nftables_text($in{'data'});
error(text('manual_evalidate', $err)) if ($err);
# Check the saved ruleset as a whole with the new content in place, as an
# included file on its own may use a define from the file that includes it.
# Put the old content back if nft rejects the result
my $old = -r $file ? read_file_contents($file) : undef;
open_lock_tempfile(my $fh, ">$file");
print_tempfile($fh, $in{'data'});
close_tempfile($fh);
my @tables = get_nftables_save($file);
sync_managed_metadata(@tables);
my $err = validate_nftables_files();
if ($err) {
if (defined($old)) {
open_lock_tempfile(my $rfh, ">$file");
print_tempfile($rfh, $old);
close_tempfile($rfh);
}
else {
unlink_file($file);
}
error(text('manual_evalidate', $err));
}
update_last_config_change();
webmin_log("manual", undef, $file);

View File

@@ -1,6 +1,6 @@
#!/usr/bin/perl
# setup.cgi
# Create a Webmin-managed nftables profile table
# Create an nftables profile table in the saved configuration
require './nftables-lib.pl'; ## no critic
use strict;

17
nftables/start.cgi Executable file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/perl
# start.cgi
# Start the system nftables service
require './nftables-lib.pl'; ## no critic
use strict;
use warnings;
our (%text);
error_setup($text{'start_err'});
assert_acl('service');
nftables_service_status() || error($text{'bootup_eservice'});
my $err = start_nftables_service();
error($err) if ($err);
webmin_log("start");
redirect("index.cgi");

17
nftables/stop.cgi Executable file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/perl
# stop.cgi
# Stop the system nftables service
require './nftables-lib.pl'; ## no critic
use strict;
use warnings;
our (%text);
error_setup($text{'stop_err'});
assert_acl('service');
nftables_service_status() || error($text{'bootup_eservice'});
my $err = stop_nftables_service();
error($err) if ($err);
webmin_log("stop");
redirect("index.cgi");

View File

@@ -40,7 +40,7 @@ $ENV{'FOREIGN_ROOT_DIRECTORY'} = $rootdir;
chdir("$bindir/..") or die "chdir: $!";
require "$bindir/../nftables-lib.pl";
our %access;
our (%access, %config);
{
local %access = (quick => 1);
@@ -214,6 +214,29 @@ is($chain->{hook}, 'input', 'chain hook');
is($chain->{priority}, '0', 'chain priority');
is($chain->{policy}, 'drop', 'chain policy');
# Policy is optional for nft base chains. This is the form used by the stock
# nftables configuration on current Debian and Ubuntu systems
my $policyless_file = write_ruleset($confdir, "policyless.nft", <<'EOF');
table inet policyless {
chain input {
type filter hook input priority filter;
}
}
EOF
my ($policyless) = get_nftables_save($policyless_file);
my $policyless_chain = $policyless->{chains}->{input};
is($policyless_chain->{type}, 'filter', 'policy-less base chain type');
is($policyless_chain->{hook}, 'input', 'policy-less base chain hook');
is($policyless_chain->{priority}, 'filter',
'policy-less base chain priority');
ok(!defined($policyless_chain->{policy}),
'policy-less base chain keeps its implicit policy');
is(scalar(@{$policyless->{rules}}), 0,
'policy-less base-chain definition is not parsed as a rule');
like(dump_nftables_save($policyless),
qr/type filter hook input priority filter;\n/,
'policy-less base chain is serialized without an invalid policy');
my $ruleset_prio = write_ruleset($confdir, "externally-managed-priority.nft", <<'EOF');
table inet externally_managed {
flags owner,persist
@@ -231,10 +254,10 @@ 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',
is(active_table_status({ family => 'inet', name => 'filter' }, [ $t ]), 'saved',
'saved active table status');
is(active_table_status({ family => 'inet', name => 'loose' }, []), 'unclaimed',
'unclaimed active table status');
is(active_table_status({ family => 'inet', name => 'loose' }, []), 'unsaved',
'unsaved 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');
@@ -295,6 +318,8 @@ 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', '0', undef),
'chain base allows an implicit accept policy');
ok(!validate_chain_base('filter', 'input', undef, 'accept'),
'chain base missing priority invalid');
ok(validate_chain_base(undef, undef, undef, undef),
@@ -492,5 +517,596 @@ ok(scalar(grep { $_->{text} eq 'ip6 daddr fe80::/64 udp dport 546 accept' }
ok(scalar(grep { $_ eq '2022' }
@{$profile_table->{sets}->{profile_hosting_tcp_ports}->{elements}}),
'profile helper includes dynamic ssh port');
is(profile_base_table_name('virtualmin'), 'webmin_profile_hosting',
'Virtualmin profiles use a Webmin-prefixed table name');
# The saved configuration is the system's own nftables file, so re-writing
# it must not discard anything the module does not model
my $sysfile = write_ruleset($confdir, 'system.nft', <<'EOF');
#!/usr/sbin/nft -f
# system firewall
flush ruleset
define lan = 192.168.0.0/24
table inet filter {
set trusted {
type ipv4_addr
flags interval
elements = { 10.0.0.0/8 }
}
map porttoip {
type inet_service : ipv4_addr
elements = { 80 : 10.0.0.1 }
}
counter http_hits {
}
chain input {
type filter hook input priority 0; policy drop;
tcp dport 22 accept
}
chain output {
type filter hook output priority 0; policy accept;
}
}
table ip nat {
chain prerouting {
type nat hook prerouting priority -100; policy accept;
}
}
include "/etc/nftables.d/*.nft"
define wan = eth0
table inet extra {
comment "hand written"
chain forward {
type filter hook forward priority 0; policy drop;
}
}
# trailing note
include "/etc/nftables.d/late.nft"
EOF
my @systables = get_nftables_save($sysfile);
is(scalar(@systables), 3, 'system ruleset table count');
is(scalar(@{$systables[0]->{raw_blocks} || []}), 2,
'unmodelled table objects are captured');
ok(exists($systables[0]->{chains}->{output}),
'chain following an unmodelled object is still parsed');
is(scalar(@{$systables[0]->{rules}}), 1,
'unmodelled objects are not parsed as rules');
my $sys_before = read_file_contents($sysfile);
ok(!rewrite_nftables_file($sysfile, \@systables),
'an unchanged system file is not rewritten');
is(read_file_contents($sysfile), $sys_before,
'an unchanged system file stays byte-for-byte identical');
# Change only the middle table. The writer must not move any top-level text,
# because defines and redefines are scoped by where they appear in the file
push(@{$systables[1]->{rules}}, {
text => 'tcp dport 8080 accept', chain => 'prerouting', index => 0 });
ok(rewrite_nftables_file($sysfile, \@systables),
'a changed table rewrites its source file');
my $rewritten = read_file_contents($sysfile);
like($rewritten, qr/^\#\!\/usr\/sbin\/nft -f/,
'shebang stays ahead of the tables');
like($rewritten, qr/flush ruleset/, 'flush ruleset is retained on disk');
like($rewritten, qr/define lan = /, 'leading define is retained');
like($rewritten,
qr/table ip nat \{.*include "\/etc\/nftables\.d\/\*\.nft".*define wan = eth0.*table inet extra \{/s,
'content between tables remains in its original position');
like($rewritten, qr/include "\/etc\/nftables\.d\/late\.nft"\s*\z/,
'trailing include remains after the tables');
like($rewritten, qr/map porttoip \{/, 'map survives a re-write');
like($rewritten, qr/counter http_hits \{/, 'named counter survives a re-write');
like($rewritten, qr/table ip nat \{/, 'second table survives a re-write');
like($rewritten, qr/comment "hand written"/, 'table comment survives a re-write');
is(scalar(() = $rewritten =~ /define wan = eth0/g), 1,
'content between tables is kept exactly once');
is_deeply([ map { $_->[0] }
sort { $a->[1] <=> $b->[1] }
map { [ $_, $systables[0]->{chains}->{$_}->{order} ] }
keys %{$systables[0]->{chains}} ],
[ 'input', 'output' ], 'chain order in the file is recorded');
like($rewritten, qr/chain input \{.*chain output \{/s,
'chains are written back in the order they were read');
my $twicefile = write_ruleset($confdir, 'twice.nft', $rewritten);
my @twice = get_nftables_save($twicefile);
ok(!rewrite_nftables_file($twicefile, \@twice),
're-writing an already written file changes nothing');
is(read_file_contents($twicefile), $rewritten,
'the second rewrite remains byte-for-byte identical');
like(dump_nftables_save($tables_prio[0]), qr/^\s*flags owner,persist$/m,
'table ownership flags survive serialization');
# Leading whitespace is valid before a table declaration, and a # inside an
# nft quoted string is data rather than the start of a source comment
my $quotedfile = write_ruleset($confdir, 'quoted.nft', <<'EOF');
table inet quoted {
chain input {
type filter hook input priority 0; policy accept;
tcp dport 22 accept comment "ticket #123"
}
}
EOF
my @quoted = get_nftables_save($quotedfile);
is(scalar(@quoted), 1, 'indented table declaration is parsed');
is($quoted[0]->{rules}->[0]->{comment}, 'ticket #123',
'hash inside a quoted comment is preserved');
my $brokenfile = write_ruleset($confdir, 'broken.nft', <<'EOF');
table inet unfinished {
chain input {
}
EOF
my $broken_before = read_file_contents($brokenfile);
eval {
no warnings 'once';
local $main::error_must_die = 1;
my @broken = get_nftables_save($brokenfile);
rewrite_nftables_file($brokenfile, \@broken);
};
like($@, qr/no recognizable closing brace/,
'an unterminated table is rejected before writing');
is(read_file_contents($brokenfile), $broken_before,
'a rejected malformed file remains unchanged');
# A table may open and close on one line. It must not capture the following
# top-level text or prevent the rest of the file from being saved
my $onelinefile = write_ruleset($confdir, 'one-line.nft', <<'EOF');
table inet first { chain hidden { counter } }
define next_port = 10000
table inet second {
chain input {
}
}
EOF
my @oneline = get_nftables_save($onelinefile);
is_deeply([ map { $_->{name} } @oneline ], [ 'first', 'second' ],
'one-line table does not swallow the following table');
is($oneline[0]->{end_line}, 1, 'one-line table span ends on its opening line');
like(dump_nftables_save($oneline[0]), qr/chain hidden \{ counter \}/,
'unmodelled content inside a one-line table is retained');
my $oneline_before = read_file_contents($onelinefile);
ok(!rewrite_nftables_file($onelinefile, \@oneline),
'an unchanged file containing a one-line table can be saved');
is(read_file_contents($onelinefile), $oneline_before,
'one-line table and following top-level text stay byte-identical');
# Balanced objects inside a multiline table remain raw and cannot hide its end.
my $inline_object_file = write_ruleset($confdir, 'inline-objects.nft', <<'EOF');
table inet inline_objects {
chain empty { }
set ports { type inet_service; elements = { 80, 443 }; }
}
table inet after_inline_objects {
}
EOF
my @inline_objects = parse_nftables_file($inline_object_file);
is(scalar(@inline_objects), 2,
'one-line objects do not swallow the following table');
is($inline_objects[0]->{end_line}, 4,
'a table containing one-line objects has the correct span');
is(scalar(@{$inline_objects[0]->{raw_blocks}}), 2,
'one-line chain and set syntax is preserved as raw content');
my $inline_before = read_file_contents($inline_object_file);
ok(!rewrite_nftables_file($inline_object_file, \@inline_objects),
'an unchanged table containing one-line objects can be saved');
is(read_file_contents($inline_object_file), $inline_before,
'one-line objects remain byte-identical');
# A file with no tables at all, such as a stock /etc/sysconfig/nftables.conf,
# keeps its comments and takes new tables at the end
my $emptyfile = write_ruleset($confdir, 'empty.nft', <<'EOF');
# Uncomment the include statement here to load the default config sample
#include "/etc/nftables/main.nft"
EOF
my $empty_table = create_profile_ruleset('new_table', 'allow_all', '*');
ok(rewrite_nftables_file($emptyfile, [ $empty_table ]),
'a table can be added to a comment-only file');
like(read_file_contents($emptyfile),
qr/Uncomment the include statement.*table inet new_table \{/s,
'comment-only header stays ahead of the new table');
our ($module_config_directory, $nftables_rules_file_cache,
$nftables_include_paths_cache, $nftables_include_cwd_cache,
$nftables_include_basedir_cache);
# A ruleset spread over a main file and the files it includes has to be read
# from, and written back to, the file each table actually lives in
my $incdir = "$confdir/nftables.d";
mkdir($incdir);
my $incmain = write_ruleset($confdir, 'main.nft', <<'EOF');
#!/usr/sbin/nft -f
flush ruleset
define lan = 192.168.0.0/24
define web_port = 80
include "nftables.d/*.nft"
table inet main_table {
chain input {
type filter hook input priority 0; policy drop;
}
}
EOF
write_ruleset($incdir, '10-web.nft', <<'EOF');
# web rules
table inet web {
chain input {
type filter hook input priority 10; policy accept;
tcp dport $web_port accept
}
}
EOF
# Deliberately non-canonical spacing, so that a needless re-write would show
write_ruleset($incdir, '20-mail.nft', <<'EOF');
table inet mail {
chain input {
type filter hook input priority 20; policy accept;
tcp dport 25 accept
}
}
EOF
{
# Match nft's root-file and compiled search paths with test directories.
local $nftables_include_paths_cache = [ $confdir ];
local $nftables_include_cwd_cache = $confdir;
local $nftables_include_basedir_cache = 1;
is_deeply([ nftables_include_files($incmain) ],
[ "$incdir/10-web.nft", "$incdir/20-mail.nft" ],
'include glob is expanded below nft search path in order');
my $dotmain = write_ruleset($confdir, 'dot-main.nft',
"include \"./nftables.d/20-mail.nft\"\n");
is_deeply([ nftables_include_files($dotmain) ],
[ "$incdir/20-mail.nft" ],
'explicitly relative include uses nft working directory');
# Current nft versions prepend the root input file's directory to the
# search path. A literal match there shadows the compiled-path copy
my $rootdir = "$confdir/root-path";
mkdir($rootdir);
my $rootchild = write_ruleset($rootdir, 'shadow.nft',
"table inet from_root { }\n");
write_ruleset($confdir, 'shadow.nft',
"table inet from_compiled_path { }\n");
my $rootmain = write_ruleset($rootdir, 'root-main.nft',
"include \"shadow.nft\"\n");
is_deeply([ nftables_include_files($rootmain) ], [ $rootchild ],
'root input directory precedes the compiled include path');
{
local $nftables_include_basedir_cache = 0;
is_deeply([ nftables_include_files($rootmain) ],
[ "$confdir/shadow.nft" ],
'older nft versions use only the compiled include path');
}
# A wildcard collects matches from every directory, and nft reads the
# compiled path's files ahead of the root input directory's files
write_ruleset($rootdir, 'glob-a.nft', "table inet glob_root { }\n");
write_ruleset($confdir, 'glob-b.nft', "table inet glob_compiled { }\n");
my $wildmain = write_ruleset($rootdir, 'wild-main.nft',
"include \"glob-*.nft\"\n");
is_deeply([ nftables_include_files($wildmain) ],
[ "$confdir/glob-b.nft", "$rootdir/glob-a.nft" ],
'wildcard includes collect every directory, compiled path first');
local $nftables_rules_file_cache = $incmain;
my @inctables = get_nftables_save();
is_deeply([ map { $_->{name} } @inctables ],
[ 'main_table', 'web', 'mail' ],
'tables are read from the main file and its includes');
is($inctables[0]->{file}, $incmain, 'main table is tagged with its file');
is($inctables[1]->{file}, "$incdir/10-web.nft",
'included table is tagged with the file it came from');
is_deeply([ get_nftables_config_files() ],
[ $incmain, "$incdir/10-web.nft", "$incdir/20-mail.nft" ],
'the manual editor offers exactly the files that are loaded');
# An included table is saved, so it must not be offered for import
is(active_table_status({ family => 'inet', name => 'web' }, \@inctables),
'saved', 'a table from an included file counts as saved');
my $main_before = read_file_contents($incmain);
my $mail_before = read_file_contents("$incdir/20-mail.nft");
# Apply uses the real saved text, so variables stay in scope, while
# includes are expanded and the broad flush command is removed
my $apply_text = nftables_apply_text($incmain);
unlike($apply_text, qr/^\s*flush\s+ruleset/m,
'apply text omits flush ruleset');
unlike($apply_text, qr/^\s*include\s/m,
'apply text expands include directives');
like($apply_text,
qr/define web_port = 80.*table inet web \{.*\$web_port/s,
'apply text keeps a define in scope for an included table');
like($apply_text, qr/table inet mail \{.*table inet main_table \{/s,
'included tables remain ahead of the following main-file table');
my $flushfile = write_ruleset($confdir, 'family-flush.nft',
"flush ruleset inet; flush ruleset ip; table inet after_flush { }\n");
my $flush_text = nftables_apply_text($flushfile);
unlike($flush_text, qr/flush\s+ruleset/,
'apply text omits family-qualified flush commands');
like($flush_text, qr/table inet after_flush \{ \}/,
'commands following family-qualified flushes are retained');
# Repeated includes are meaningful in nft. Application must expand every
# occurrence even though the UI lists each included file only once
my $repeat_child = write_ruleset($confdir, 'repeat-child.nft',
"add rule inet repeated input counter comment \"repeat marker\"\n");
my $repeat_main = write_ruleset($confdir, 'repeat-main.nft', <<'EOF');
table inet repeated {
chain input {
}
}
include "repeat-child.nft"
include "repeat-child.nft"
EOF
is_deeply([ nftables_include_files($repeat_main) ], [ $repeat_child ],
'configuration file list de-duplicates repeated includes');
my $repeat_text = nftables_apply_text($repeat_main);
is(scalar(() = $repeat_text =~ /repeat marker/g), 2,
'apply text expands every repeated include');
# Editing a table in an included file writes it back there
my ($web) = grep { $_->{name} eq 'web' } @inctables;
push(@{$web->{rules}}, {'text' => 'tcp dport 443 accept',
'chain' => 'input', 'index' => 99});
write_configuration(@inctables);
like(read_file_contents("$incdir/10-web.nft"), qr/tcp dport 443 accept/,
'edit lands in the included file');
unlike(read_file_contents($incmain), qr/table inet web/,
'edit is not copied into the main file');
is(read_file_contents($incmain), $main_before,
'the main file is left alone');
is(read_file_contents("$incdir/20-mail.nft"), $mail_before,
'an untouched included file is not re-written');
# Deleting it empties that file without disturbing the others
my @keep = grep { $_->{name} ne 'web' } get_nftables_save();
write_configuration(@keep);
unlike(read_file_contents("$incdir/10-web.nft"), qr/table\s/,
'deleted table is removed from its own file');
like(read_file_contents("$incdir/10-web.nft"), qr/# web rules/,
'the emptied file keeps its own comments');
is(read_file_contents($incmain), $main_before,
'deleting from an include leaves the main file alone');
is_deeply([ map { $_->{name} } get_nftables_save() ],
[ 'main_table', 'mail' ], 'the deleted table is gone');
# An include loop must not send the parser into a spin
my $loop_a = write_ruleset($confdir, 'loop-a.nft',
"include \"loop-b.nft\"\n");
write_ruleset($confdir, 'loop-b.nft', "include \"loop-a.nft\"\n");
is_deeply([ nftables_include_files($loop_a) ],
[ "$confdir/loop-b.nft" ],
'an include loop terminates');
eval {
no warnings 'once';
local $main::error_must_die = 1;
nftables_apply_text($loop_a);
};
like($@, qr/include files form a loop/,
'an include loop is rejected before applying');
}
# Migration must preserve rules before removing the private files and boot
# action
mkdir($module_config_directory) if (!-d $module_config_directory);
my $legacy = write_ruleset($module_config_directory, 'rules.conf', <<'EOF');
# This file was auto-generated by the module.
# Manual changes may be overwritten.
table inet profile_hosting {
chain input {
type filter hook input priority 0; policy drop;
tcp dport 10000 accept
}
}
table inet standard_collision {
chain deprecated_copy {
}
}
table inet destination_collision {
chain deprecated_destination_copy {
}
}
table inet numbered_collision {
chain deprecated_numbered_copy {
}
}
EOF
my $custom_legacy = write_ruleset($confdir, 'custom-legacy.nft', <<'EOF');
table inet profile_hosting {
chain input {
type filter hook input priority 0; policy drop;
tcp dport 9999 accept
}
}
table inet legacy_extra {
}
EOF
my $now = time();
utime($now - 120, $now - 120, $custom_legacy);
utime($now, $now, $legacy);
my $target = write_ruleset($confdir, 'migrate-target.nft', <<'EOF');
# Uncomment the include statement here to load the default config sample
#include "/etc/nftables/main.nft"
table inet retained {
chain standard_table {
}
}
table inet standard_collision {
chain standard_copy {
}
}
table inet webmin_destination_collision {
chain existing_prefixed_copy {
}
}
table inet webmin_numbered_collision {
chain existing_numbered_copy {
}
}
table inet webmin_numbered_collision_migrated {
chain existing_migrated_copy {
}
}
EOF
chmod(0600, $target);
{
local $nftables_rules_file_cache = $target;
local $config{'save_file'} = $custom_legacy;
no warnings 'redefine';
my (@validated, @validated_modes);
local *validate_nftables_files = sub {
my ($candidate) = @_;
push(@validated, read_file_contents($candidate));
push(@validated_modes, (stat($candidate))[2] & 07777);
return undef;
};
is(migrate_legacy_nftables_config(), 5,
'unique tables from every legacy file are migrated');
ok(!-e $legacy, 'legacy rules file is deleted after migration');
ok(!-e $legacy.'.migrated', 'migration does not retain a backup');
ok(!-e $custom_legacy, 'custom legacy rules file is deleted');
ok(!-e $custom_legacy.'.migrated',
'custom legacy file leaves no backup');
is(scalar(@validated), 1, 'the complete candidate is validated once');
is(sprintf('%04o', $validated_modes[0]), '0600',
'the candidate is no more readable than the system file');
is(sprintf('%04o', (stat($target))[2] & 07777), '0600',
'migration keeps the system file mode');
my @moved = get_nftables_save($target);
is(scalar(@moved), 10, 'migrated tables join the system configuration');
is_deeply([ sort map { $_->{name} } @moved ],
[ 'retained', 'standard_collision',
'webmin_destination_collision',
'webmin_destination_collision_migrated',
'webmin_legacy_extra', 'webmin_numbered_collision',
'webmin_numbered_collision_migrated',
'webmin_numbered_collision_migrated_2',
'webmin_profile_hosting',
'webmin_standard_collision' ],
'migrated tables receive Webmin-prefixed names');
my ($profile) = grep { $_->{name} eq 'webmin_profile_hosting' } @moved;
is($profile->{rules}->[0]->{dport}, '10000',
'the newest duplicate legacy table is migrated');
like(read_file_contents($target), qr/Uncomment the include statement/,
'migration keeps the system file comments');
like(read_file_contents($target), qr/chain standard_table/,
'migration preserves the existing system table');
like(read_file_contents($target), qr/chain standard_copy/,
'migration keeps a system table whose name matches a legacy table');
like(read_file_contents($target), qr/chain deprecated_copy/,
'an old-name match is migrated under the Webmin prefix');
like(read_file_contents($target), qr/chain existing_prefixed_copy/,
'migration preserves an existing prefixed table');
like(read_file_contents($target), qr/chain deprecated_destination_copy/,
'a prefixed-name collision receives a migration suffix');
like(read_file_contents($target), qr/chain deprecated_numbered_copy/,
'migration adds a number when the suffix is already used');
# Re-running cleanup after the system file was saved must not duplicate
# a table under the next available suffix.
my $before_retry = read_file_contents($target);
my ($numbered) = grep {
$_->{name} eq 'webmin_numbered_collision_migrated_2'
} @moved;
my %retry = %$numbered;
$retry{name} = 'numbered_collision';
delete($retry{file});
write_ruleset($module_config_directory, 'rules.conf',
dump_nftables_save(\%retry));
is(migrate_legacy_nftables_config(), 0,
'an identical migrated table is not duplicated');
is(read_file_contents($target), $before_retry,
'cleanup retry leaves the system configuration unchanged');
ok(!-e $legacy, 'cleanup retry removes the legacy file');
is(migrate_legacy_nftables_config(), 0, 'migration only runs once');
}
# A missing system file is installed atomically with a private mode.
my $new_legacy = write_ruleset($module_config_directory, 'rules.conf', <<'EOF');
table inet create_target {
}
EOF
my $new_target = "$confdir/new-migrate-target.nft";
{
local $nftables_rules_file_cache = $new_target;
no warnings 'redefine';
local *validate_nftables_files = sub { return; };
is(migrate_legacy_nftables_config(), 1,
'migration creates a missing system file');
is(sprintf('%04o', (stat($new_target))[2] & 07777), '0600',
'a new system file is private');
ok(!-e $new_legacy,
'creating the system file removes the legacy file');
}
# A rejected candidate must leave both source configurations byte-identical.
my $failed_legacy = write_ruleset($module_config_directory, 'rules.conf', <<'EOF');
table inet retry_me {
}
EOF
my $failed_target = write_ruleset($confdir, 'failed-migrate-target.nft', <<'EOF');
table inet keep_me {
}
EOF
my $failed_before = read_file_contents($failed_target);
{
local $nftables_rules_file_cache = $failed_target;
no warnings 'redefine';
local *validate_nftables_files = sub { return 'test rejection'; };
local $main::error_must_die = 1;
eval { migrate_legacy_nftables_config(); };
like($@, qr/test rejection/, 'invalid migration candidate is rejected');
is(read_file_contents($failed_target), $failed_before,
'failed migration leaves the system configuration unchanged');
ok(-e $failed_legacy,
'failed migration leaves the deprecated configuration in place');
}
# An existing destination that cannot be read must never be treated as empty.
my $unreadable_target = write_ruleset($confdir, 'unreadable-target.nft', <<'EOF');
table inet keep_unreadable {
}
EOF
my $unreadable_before = read_file_contents($unreadable_target);
chmod(0000, $unreadable_target);
SKIP: {
if (-r $unreadable_target) {
chmod(0600, $unreadable_target);
skip('the test user can read mode 0000 files', 3);
}
local $nftables_rules_file_cache = $unreadable_target;
local $main::error_must_die = 1;
eval { migrate_legacy_nftables_config(); };
like($@, qr/Failed to read/, 'unreadable system configuration is rejected');
chmod(0600, $unreadable_target);
is(read_file_contents($unreadable_target), $unreadable_before,
'unreadable system configuration is not replaced');
ok(-e $failed_legacy,
'unreadable system configuration leaves deprecated rules in place');
}
done_testing();