From 3c9d53109bacbf9021aed5994c329d3a67c6c8ca Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Sat, 2 May 2026 19:02:37 +0200 Subject: [PATCH] Fix to rework nftables management around saved tables Rework the nftables module so Webmin manages its saved nftables configuration as the source of truth instead of directly editing the live ruleset. Add an active ruleset view for inspecting live tables and importing copies into Webmin-managed config if needed, track managed and imported tables with metadata, and prevent externally managed tables from being overwritten during apply. Co-authored-by: Copilot --- nftables/active.cgi | 57 ++++++++ nftables/active_table.cgi | 108 +++++++++++++++ nftables/apply.cgi | 4 +- nftables/config | 1 - nftables/config.info | 1 - nftables/create_table.cgi | 9 ++ nftables/import_table.cgi | 102 ++++++++++++++ nftables/index.cgi | 18 +-- nftables/install_check.pl | 8 +- nftables/lang/en | 39 +++++- nftables/nftables-lib.pl | 272 +++++++++++++++++++++++++++----------- nftables/save_rule.cgi | 5 +- nftables/setup.cgi | 10 +- nftables/t/run-tests.t | 8 ++ 14 files changed, 534 insertions(+), 108 deletions(-) create mode 100755 nftables/active.cgi create mode 100755 nftables/active_table.cgi create mode 100755 nftables/import_table.cgi diff --git a/nftables/active.cgi b/nftables/active.cgi new file mode 100755 index 000000000..4f7843b90 --- /dev/null +++ b/nftables/active.cgi @@ -0,0 +1,57 @@ +#!/usr/bin/perl +# active.cgi +# Show active nftables tables for viewing and import + +require './nftables-lib.pl'; ## no critic +use strict; +use warnings; +our (%text); + +ui_print_header(undef, $text{'active_title'}, "", "intro", 1, 1); + +my ($tables, $err) = get_active_nftables_save(); +if ($err) { + print text('active_failed', $err); +} +elsif (!@$tables) { + print "$text{'active_none'}

\n"; +} +else { + my @saved_tables = get_nftables_save(); + print ui_columns_start( + [ $text{'active_table'}, $text{'active_flags'}, + $text{'active_chains'}, $text{'active_sets'}, + $text{'active_rules'}, $text{'active_status'}, + $text{'index_actions'} ], 100); + foreach my $t (@$tables) { + my $chains = $t->{'chains'} && ref($t->{'chains'}) eq 'HASH' ? + scalar(keys %{$t->{'chains'}}) : 0; + my $sets = $t->{'sets'} && ref($t->{'sets'}) eq 'HASH' ? + scalar(keys %{$t->{'sets'}}) : 0; + my $rules = $t->{'rules'} && ref($t->{'rules'}) eq 'ARRAY' ? + scalar(@{$t->{'rules'}}) : 0; + 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 $table_url = "active_table.cgi?family=".urlize($t->{'family'}). + "&name=".urlize($t->{'name'}); + my $actions = $is_saved ? "-" : + ui_link( + "import_table.cgi?family=".urlize($t->{'family'}). + "&name=".urlize($t->{'name'}), + $text{'active_import'}); + print ui_columns_row([ + ui_link($table_url, html_escape(nft_table_spec($t))), + html_escape($flags), + $chains, + $sets, + $rules, + $status, + $actions, + ]); + } + print ui_columns_end(); +} + +ui_print_footer("index.cgi", $text{'index_return'}); diff --git a/nftables/active_table.cgi b/nftables/active_table.cgi new file mode 100755 index 000000000..082aada68 --- /dev/null +++ b/nftables/active_table.cgi @@ -0,0 +1,108 @@ +#!/usr/bin/perl +# active_table.cgi +# Show a read-only active nftables table + +require './nftables-lib.pl'; ## no critic +use strict; +use warnings; +our (%in, %text); +ReadParse(); +error_setup($text{'active_table_err'}); + +my ($tables, $err) = get_active_nftables_save(); +error(text('active_failed', $err)) if ($err); + +my $table; +foreach my $t (@$tables) { + if ($t->{'family'} eq $in{'family'} && $t->{'name'} eq $in{'name'}) { + $table = $t; + last; + } +} +$table || error($text{'active_table_notable'}); +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); + +ui_print_header(undef, $text{'active_table_title'}, "", "intro", 1, 1); + +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}); +print ui_table_end(); + +if (!$is_saved) { + print ui_buttons_start(); + print ui_buttons_row( + "import_table.cgi?family=".urlize($table->{'family'}). + "&name=".urlize($table->{'name'}), + $text{'active_import'}, $text{'active_importdesc'}); + print ui_buttons_end(); + } + +my ($chains_html, $sets_html); + +$chains_html .= ui_columns_start( + [ $text{'index_chain_col'}, $text{'index_type'}, $text{'index_hook'}, + $text{'index_priority'}, $text{'index_policy_col'}, $text{'index_rules'} ], + 100); +foreach my $c (sort keys %{$table->{'chains'}}) { + my $chain_def = $table->{'chains'}->{$c} || { }; + my $policy = $chain_def->{'policy'}; + my $policy_label = $policy ? + ($text{'index_policy_'.lc($policy)} || uc($policy)) : "-"; + my @rules = grep { $_->{'chain'} eq $c } @{$table->{'rules'}}; + my $rules_html = @rules ? + ui_tag('div', + join("", map { + ui_tag('div', describe_rule($_), + { 'class' => 'nftables_rule_text' }) + } @rules), + { 'class' => 'nftables_rules_list', + 'style' => 'display: grid; row-gap: 0.25em;' }) : + ui_tag('i', $text{'index_rules_none'}); + $chains_html .= ui_columns_row([ + html_escape($c), + html_escape($chain_def->{'type'} || "-"), + html_escape($chain_def->{'hook'} || "-"), + defined($chain_def->{'priority'}) ? + html_escape($chain_def->{'priority'}) : "-", + html_escape($policy_label), + $rules_html, + ]); +} +$chains_html .= ui_columns_end(); + +$sets_html .= ui_columns_start( + [ $text{'index_set_name'}, $text{'index_set_type'}, + $text{'index_set_flags'}, $text{'index_set_elements'} ], 100); +if ($table->{'sets'} && ref($table->{'sets'}) eq 'HASH') { + foreach my $s (sort keys %{$table->{'sets'}}) { + my $set = $table->{'sets'}->{$s} || { }; + $sets_html .= ui_columns_row([ + html_escape($s), + html_escape($set->{'type'} || "-"), + html_escape($set->{'flags'} || "-"), + html_escape(set_elements_summary($set)), + ]); + } +} +$sets_html .= ui_columns_end(); + +my @tabs = ( + [ 'chains', $text{'index_tab_chains'} ], + [ 'sets', $text{'index_tab_sets'} ], + ); +my $tab = $in{'view'} && $in{'view'} eq 'sets' ? 'sets' : 'chains'; +print ui_hr(); +print ui_tabs_start(\@tabs, "view", $tab, 1); +print ui_tabs_start_tab("view", "chains"); +print $chains_html; +print ui_tabs_end_tab(); +print ui_tabs_start_tab("view", "sets"); +print $sets_html; +print ui_tabs_end_tab(); +print ui_tabs_end(1); + +ui_print_footer("active.cgi", $text{'active_return'}); diff --git a/nftables/apply.cgi b/nftables/apply.cgi index f00267123..4fa246ec1 100755 --- a/nftables/apply.cgi +++ b/nftables/apply.cgi @@ -5,12 +5,10 @@ require './nftables-lib.pl'; ## no critic use strict; use warnings; -our (%config, %in, %text); +our (%in, %text); ReadParse(); error_setup($text{'apply_err'}); -redirect("index.cgi") if ($config{'direct'}); - my $err = apply_restore(); error($err) if ($err); diff --git a/nftables/config b/nftables/config index 1116a423b..455fdd0d4 100644 --- a/nftables/config +++ b/nftables/config @@ -1,4 +1,3 @@ -direct=0 perpage=50 view_condition=1 view_comment=1 diff --git a/nftables/config.info b/nftables/config.info index 2c5b4ca98..e97a45585 100644 --- a/nftables/config.info +++ b/nftables/config.info @@ -9,4 +9,3 @@ after_apply_cmd=Command to run after applying configuration,3,None line2=NFTables configuration,11 nft_cmd=Full path to nft command,0 save_file=File to save/edit NFTables rules,3,Use operating system or Webmin default -direct=Directly edit firewall rules instead of save file?,1,1-Yes,0-No diff --git a/nftables/create_table.cgi b/nftables/create_table.cgi index 4c5718354..e6842d749 100755 --- a/nftables/create_table.cgi +++ b/nftables/create_table.cgi @@ -25,6 +25,15 @@ if ($in{'create'}) { error($text{'create_edup'}); } } + my ($active, $active_err) = get_active_nftables_save(); + if (!$active_err) { + foreach my $t (@$active) { + if ($t->{'name'} eq $name && $t->{'family'} eq $family && + table_is_externally_managed($t)) { + error(text('create_eexternal', nft_table_spec($t))); + } + } + } my $table = { 'name' => $name, 'family' => $family, diff --git a/nftables/import_table.cgi b/nftables/import_table.cgi new file mode 100755 index 000000000..fd1dbd8ee --- /dev/null +++ b/nftables/import_table.cgi @@ -0,0 +1,102 @@ +#!/usr/bin/perl +# import_table.cgi +# Import an active nftables table as a Webmin-managed saved table + +require './nftables-lib.pl'; ## no critic +use strict; +use warnings; +use Storable qw(dclone); +our (%in, %text); +ReadParse(); +error_setup($text{'import_err'}); + +my ($active, $active_err) = get_active_nftables_save(); +error(text('active_failed', $active_err)) if ($active_err); + +my $source; +foreach my $t (@$active) { + if ($t->{'family'} eq $in{'family'} && $t->{'name'} eq $in{'name'}) { + $source = $t; + last; + } +} +$source || error($text{'import_esource'}); + +my @tables = get_nftables_save(); +if (table_is_webmin_managed($source, \@tables)) { + error(text('import_emanaged', nft_table_spec($source))); +} + +if ($in{'import'}) { + my $name = $in{'new_name'}; + $name =~ /^\w[\w-]*$/ || error($text{'create_ename'}); + foreach my $t (@tables) { + if ($t->{'family'} eq $source->{'family'} && $t->{'name'} eq $name) { + error($text{'create_edup'}); + } + } + foreach my $t (@$active) { + if ($t->{'family'} eq $source->{'family'} && $t->{'name'} eq $name && + table_is_externally_managed($t)) { + error(text('import_eexternal', nft_table_spec($t))); + } + } + + my $import = dclone($source); + $import->{'name'} = $name; + delete($import->{'flags'}); + 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=".urlize($source->{'family'}). + "&table_name=".urlize($name)); +} + +ui_print_header(undef, $text{'import_title'}, "", "intro", 1, 1); + +print ui_form_start("import_table.cgi"); +print ui_hidden("family", $source->{'family'}); +print ui_hidden("name", $source->{'name'}); +print ui_hidden("import", 1); + +print ui_table_start($text{'import_header'}, "width=100%", 2); +print ui_table_row($text{'import_source'}, html_escape(nft_table_spec($source))); +print ui_table_row($text{'import_flags'}, html_escape($source->{'flags'} || "-")); +if (table_is_externally_managed($source)) { + print ui_table_row("", $text{'import_external_note'}); +} +print ui_table_row($text{'import_new_name'}, + ui_textbox("new_name", unique_import_table_name($source, \@tables, $active), 30)); +print ui_table_end(); + +print ui_form_end([ [ undef, $text{'import_ok'} ] ]); +ui_print_footer("active.cgi", $text{'active_return'}); + +sub unique_import_table_name +{ +my ($source, $saved, $active_tables) = @_; +my $base = "imported_".$source->{'name'}; +$base =~ s/[^\w-]/_/g; +$base = "imported_table" if ($base !~ /^\w/); + +my %used; +foreach my $list ($saved, $active_tables) { + foreach my $t (@$list) { + next if ($t->{'family'} ne $source->{'family'}); + $used{$t->{'name'}} = 1; + } +} +my $name = $base; +my $i = 1; +while ($used{$name}) { + $name = $base."_".$i++; +} +return $name; +} diff --git a/nftables/index.cgi b/nftables/index.cgi index ea4cb57c1..15ccf82ba 100755 --- a/nftables/index.cgi +++ b/nftables/index.cgi @@ -22,17 +22,6 @@ if (!$cmd) { exit; } -# Check if kernel supports it (basic check) -my $out = backquote_command("$cmd list ruleset 2>&1"); -if ($? && $out !~ /no ruleset/i) { - # If it fails and not just empty - print text('index_ekernel', "

$out
"); - if (!$partial) { - ui_print_footer("/", $text{'index'}); - } - exit; -} - # Load tables my @tables = get_nftables_save(); my $rules_html = ""; @@ -43,6 +32,8 @@ if (!@tables) { $rules_html .= ui_buttons_row("setup.cgi", $text{'index_setup'}, $text{'index_setupdesc'}); $rules_html .= ui_buttons_row("create_table.cgi", $text{'index_table_create'}, $text{'index_table_createdesc'}); + $rules_html .= ui_buttons_row("active.cgi", $text{'index_active'}, + $text{'index_activedesc'}); $rules_html .= ui_buttons_end(); } else { # Select table @@ -71,7 +62,7 @@ if (!@tables) { if (!$partial) { print ui_form_start("index.cgi"); print "
\n"; - print text('index_change')," "; + print text('index_change'),"  "; print ui_select("table", $in{'table'}, \@table_opts, 1, 0, 1, 0, "onchange='this.form.querySelector(\"[name=nft_submit]\").click()'"); print ui_submit("", "nft_submit", 0, "style='display:none'"); @@ -267,10 +258,11 @@ if ($partial) { print $rules_html; -if (@tables && !$config{'direct'}) { +if (@tables) { print ui_hr(); print ui_buttons_start(); print ui_buttons_row("apply.cgi", $text{'index_apply'}, $text{'index_applydesc'}); + print ui_buttons_row("active.cgi", $text{'index_active'}, $text{'index_activedesc'}); print ui_buttons_end(); } diff --git a/nftables/install_check.pl b/nftables/install_check.pl index 934cb882d..87ae0fd1d 100644 --- a/nftables/install_check.pl +++ b/nftables/install_check.pl @@ -16,11 +16,9 @@ sub is_installed my ($mode) = @_; return 0 if (&check_nftables()); if ($mode) { - if (!$config{'direct'}) { - my $file = $config{'save_file'} || - "$module_config_directory/nftables.conf"; - return 1 if (!-s $file); - } + my $file = $config{'save_file'} || + "$module_config_directory/rules.conf"; + return 1 if (!-s $file); return 2; } return 1; diff --git a/nftables/lang/en b/nftables/lang/en index 92da50ba6..5fb0b0ddd 100644 --- a/nftables/lang/en +++ b/nftables/lang/en @@ -5,7 +5,7 @@ index_editing=Rules file $1 index_ecommand=The NFTables command $1 was not found on your system. index_ekernel=An error occurred checking your current NFTables configuration: $1 index_header=Existing NFTables Rules -index_change=Show table: +index_change=Managing table index_table_filter=Packet filtering index_table_nat=Network address translation index_table_mangle=Packet alteration @@ -51,7 +51,9 @@ index_cdeletesel=Delete Selected Chains index_cmovesel=Move Selected index_radd=Add Rule index_apply=Apply Configuration -index_applydesc=Click this button to load the saved firewall configuration into the active nftables ruleset. +index_applydesc=Click this button to replace the saved Webmin-managed tables in the active nftables ruleset. +index_active=View Active Ruleset +index_activedesc=View active nftables tables and import copies into Webmin's saved configuration. index_unapply=Revert Configuration index_unapplydesc=Click this button to reset the configuration listed above to the one that is currently active. index_bootup=Activate at Boot @@ -65,6 +67,8 @@ save=Save delete=Delete save_err=Failed to save rule apply_err=Failed to apply configuration +apply_enone=No saved nftables tables were found to apply. +apply_eexternal=Cannot apply configuration because table $1 is currently marked as externally managed. setup_title=Setup Default Ruleset setup_header=Create Default Ruleset setup_desc=This page allows you to create a default nftables ruleset. Select one of the options below and click 'Create'. @@ -189,6 +193,7 @@ create_failed=Failed to create table:
$1
create_ename=Table name is invalid create_edup=A table with that name and family already exists create_efamily=Invalid table family selected +create_eexternal=Cannot create table $1 because that active table is externally managed. delete_title=Delete table delete_err=Failed to delete table delete_failed=Failed to delete table:
$1
@@ -228,3 +233,33 @@ delete_sets_failed=Failed to delete selected sets:
$1
delete_sets_enone=No sets selected delete_sets_noset=No such set selected: $1 delete_sets_inuse=The selected sets are referenced by $1 rule(s). Remove those rules first. +active_title=Active Ruleset +active_failed=Failed to read active nftables ruleset: $1 +active_none=No active nftables tables were found. +active_table=Table +active_flags=Flags +active_chains=Chains +active_sets=Sets +active_rules=Rules +active_status=Status +active_webmin=Managed by Webmin +active_external=Externally managed +active_unclaimed=Unclaimed +active_import=Import Copy +active_importdesc=Import this active table as a separate Webmin-managed table. +active_return=active ruleset +active_table_title=Active Table +active_table_err=Failed to view active table +active_table_notable=No such active table selected +active_table_summary=Active table details +import_title=Import active table +import_header=Import table as Webmin-managed copy +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_source=Source table +import_flags=Source flags +import_external_note=This active table is marked as externally managed. Importing creates a separate Webmin-managed copy and does not change the active source table. +import_new_name=New table name +import_ok=Import Copy diff --git a/nftables/nftables-lib.pl b/nftables/nftables-lib.pl index 4e5475636..7de4e04c4 100644 --- a/nftables/nftables-lib.pl +++ b/nftables/nftables-lib.pl @@ -29,16 +29,11 @@ return text('index_ecommand', "nft"); sub get_nftables_save { my ($file) = @_; -my $cmd = get_nft_command(); if (!$file) { - if ($config{'direct'}) { - return ( ) if (!$cmd); - $file = "$cmd list ruleset |"; - } else { - $file = $config{'save_file'} || "$module_config_directory/nftables.conf"; - } + $file = $config{'save_file'} || "$module_config_directory/rules.conf"; } return ( ) if (!$file); +return ( ) if ($file !~ /\|\s*$/ && !-r $file); my @rv; my $table; @@ -129,6 +124,9 @@ for(my $i=0; $i<@lines; $i++) { push(@rv, $table); $chain = undef; } + elsif ($line =~ /^\s*flags\s+(.+?)\s*;?$/ && $table && !$chain) { + $table->{'flags'} = $1; + } elsif ($line =~ /^\s*set\s+(\S+)\s+\{/) { # Start of a set if ($table) { @@ -186,6 +184,25 @@ for(my $i=0; $i<@lines; $i++) { return @rv; } +# get_active_nftables_save() +# Returns an array ref of tables from the active ruleset, and an optional error +sub get_active_nftables_save +{ +my $cmd = get_nft_command(); +return (undef, text('index_ecommand', "nft")) if (!$cmd); + +my $out = backquote_command("$cmd list ruleset 2>&1"); +return (undef, "
$out
") if ($?); + +my $tmp = tempname(); +open_tempfile(my $fh, ">$tmp"); +print_tempfile($fh, $out); +close_tempfile($fh); +my @tables = get_nftables_save($tmp); +unlink_file($tmp); +return (\@tables, undef); +} + sub tokenize_nft_rule { my ($line) = @_; @@ -1003,11 +1020,12 @@ sub write_configuration { my (@tables) = @_; my $out = dump_nftables_save(@tables); -my $file = $config{'save_file'} || "$module_config_directory/nftables.conf"; +my $file = $config{'save_file'} || "$module_config_directory/rules.conf"; open_tempfile(my $fh, ">$file"); print_tempfile($fh, $out); close_tempfile($fh); +sync_managed_metadata(@tables); return; } @@ -1024,69 +1042,81 @@ my ($table) = @_; } # save_configuration(@tables) -# Writes the configuration to the save file. If direct mode is on, applies it -# without creating a persistent save file. +# Writes the configuration to the save file sub save_configuration { my (@tables) = @_; -if ($config{'direct'}) { - my $tmp = tempname(); - open_tempfile(my $fh, ">$tmp"); - print_tempfile($fh, dump_nftables_save(@tables)); - close_tempfile($fh); - my $err = apply_restore($tmp); - unlink_file($tmp); - return $err; -} write_configuration(@tables); return; } # create_table_configuration(&table, @tables) -# Writes the full configuration, but in direct mode creates only the selected table +# Writes the full configuration after creating a table sub create_table_configuration { my ($table, @tables) = @_; -if ($config{'direct'}) { - return apply_table_restore($table, 0); -} write_configuration(@tables); return; } # save_table_configuration(&table, @tables) -# Writes the full configuration, but in direct mode replaces only the selected table +# Writes the full configuration after changing a table sub save_table_configuration { my ($table, @tables) = @_; -if ($config{'direct'}) { - return apply_table_restore($table, 1); -} write_configuration(@tables); return; } # delete_table_configuration(&table, @tables) -# Writes the full configuration, but in direct mode deletes only the selected table +# Writes the full configuration after deleting a table sub delete_table_configuration { my ($table, @tables) = @_; -if ($config{'direct'}) { - return apply_table_delete($table); -} write_configuration(@tables); return; } # apply_restore([file]) -# Applies the configuration from the save file +# Applies Webmin-managed tables from the save file sub apply_restore { my ($file) = @_; -$file ||= $config{'save_file'} || "$module_config_directory/nftables.conf"; +$file ||= $config{'save_file'} || "$module_config_directory/rules.conf"; my $cmd = get_nft_command(); return text('index_ecommand', "nft") if (!$cmd); -my $out = backquote_logged("$cmd -f $file 2>&1"); + +my @tables = get_nftables_save($file); +return text('apply_enone') if (!@tables); + +my ($active, $active_err) = get_active_nftables_save(); +return $active_err if ($active_err); + +my %active; +foreach my $t (@$active) { + $active{table_key($t)} = $t; +} +foreach my $t (@tables) { + my $active_table = $active{table_key($t)}; + if ($active_table && table_is_externally_managed($active_table)) { + return text('apply_eexternal', nft_table_spec($t)); + } +} + +my $tmp = tempname(); +open_tempfile(my $fh, ">$tmp"); +foreach my $t (@tables) { + print_tempfile($fh, "delete table ".nft_table_spec($t)."\n") + if ($active{table_key($t)}); +} +print_tempfile($fh, dump_nftables_save(@tables)); +close_tempfile($fh); + +my $out = backquote_logged("$cmd -c -f $tmp 2>&1"); +if (!$?) { + $out = backquote_logged("$cmd -f $tmp 2>&1"); +} +unlink_file($tmp); if ($?) { return "
$out
"; } @@ -1102,54 +1132,148 @@ return $table->{'family'} ? "$table->{'family'} $table->{'name'}" : $table->{'name'}; } -# apply_table_restore(&table, [replace-existing]) -# Applies a single table without touching unrelated tables -sub apply_table_restore +# table_key(&table) +# Returns a stable key for a table +sub table_key { -my ($table, $replace) = @_; -my $cmd = get_nft_command(); -return text('index_ecommand', "nft") if (!$cmd); - -my $spec = nft_table_spec($table); -my $tmp = tempname(); -open_tempfile(my $fh, ">$tmp"); -print_tempfile($fh, "delete table $spec\n") if ($replace); -print_tempfile($fh, dump_nftables_save($table)); -close_tempfile($fh); - -my $out = backquote_logged("$cmd -c -f $tmp 2>&1"); -if (!$?) { - $out = backquote_logged("$cmd -f $tmp 2>&1"); +my ($table) = @_; +return ($table->{'family'} || '')."\0".($table->{'name'} || ''); } -unlink_file($tmp); -if ($?) { - return "
$out
"; + +# table_is_externally_managed(&table) +# Returns true if an active table is marked as owned by another program +sub table_is_externally_managed +{ +my ($table) = @_; +return 0 if (!$table || !$table->{'flags'}); +my %flags = map { $_ => 1 } grep { $_ ne '' } split(/[,\s]+/, $table->{'flags'}); +return $flags{'owner'} || $flags{'persist'}; } + +# table_is_webmin_managed(&table, [&saved_tables]) +# Returns true if an active table is present in Webmin's saved config +sub table_is_webmin_managed +{ +my ($table, $saved_tables) = @_; +if (!$saved_tables) { + my @tables = get_nftables_save(); + $saved_tables = \@tables; + } +foreach my $t (@$saved_tables) { + return 1 if (table_key($t) eq table_key($table)); + } +return 0; +} + +# active_table_status(&table, [&saved_tables]) +# Returns webmin, external or unclaimed for an active table +sub active_table_status +{ +my ($table, $saved_tables) = @_; +return "external" if (table_is_externally_managed($table)); +return "webmin" if (table_is_webmin_managed($table, $saved_tables)); +return "unclaimed"; +} + +# managed_metadata_file() +# Returns the path to Webmin's nftables metadata file +sub managed_metadata_file +{ +return "$module_config_directory/managed.json"; +} + +# managed_table_key(&table) +# Returns the key used for managed table metadata +sub managed_table_key +{ +my ($table) = @_; +return nft_table_spec($table); +} + +# read_managed_metadata() +# Returns metadata about tables managed by this module +sub read_managed_metadata +{ +my $file = managed_metadata_file(); +return { 'tables' => { } } if (!-r $file); +my $json = read_file_contents($file); +my $meta = eval { convert_from_json($json) }; +if (!$meta || ref($meta) ne 'HASH') { + $meta = { }; + } +if (!$meta->{'tables'} || ref($meta->{'tables'}) ne 'HASH') { + $meta->{'tables'} = { }; + } +return $meta; +} + +# write_managed_metadata(&metadata) +# Writes metadata about tables managed by this module +sub write_managed_metadata +{ +my ($meta) = @_; +$meta ||= { }; +$meta->{'tables'} = { } if (ref($meta->{'tables'}) ne 'HASH'); +my $file = managed_metadata_file(); +lock_file($file); +write_file_contents($file, convert_to_json($meta, 1)); +unlock_file($file); return; } -# apply_table_delete(&table) -# Deletes a single active table without touching unrelated tables -sub apply_table_delete +# sync_managed_metadata(@tables) +# Keeps managed metadata aligned with the saved Webmin config +sub sync_managed_metadata +{ +my (@tables) = @_; +my $meta = read_managed_metadata(); +my %old = %{$meta->{'tables'}}; +my %new; +foreach my $t (@tables) { + my $key = managed_table_key($t); + my %entry = $old{$key} && ref($old{$key}) eq 'HASH' ? + %{$old{$key}} : ( ); + $entry{'family'} = $t->{'family'}; + $entry{'name'} = $t->{'name'}; + $entry{'source'} ||= 'webmin'; + $entry{'managed_at'} ||= time(); + $new{$key} = \%entry; + } +$meta->{'tables'} = \%new; +write_managed_metadata($meta); +return; +} + +# register_managed_table(&table, %info) +# Adds or updates metadata for a Webmin-managed table +sub register_managed_table +{ +my ($table, %info) = @_; +my $meta = read_managed_metadata(); +my $key = managed_table_key($table); +my %entry = $meta->{'tables'}->{$key} && + ref($meta->{'tables'}->{$key}) eq 'HASH' ? + %{$meta->{'tables'}->{$key}} : ( ); +foreach my $k (keys %info) { + $entry{$k} = $info{$k}; + } +$entry{'family'} = $table->{'family'}; +$entry{'name'} = $table->{'name'}; +$entry{'source'} ||= 'webmin'; +$entry{'managed_at'} ||= time(); +$meta->{'tables'}->{$key} = \%entry; +write_managed_metadata($meta); +return; +} + +# unregister_managed_table(&table) +# Removes metadata for a table no longer managed by this module +sub unregister_managed_table { my ($table) = @_; -my $cmd = get_nft_command(); -return text('index_ecommand', "nft") if (!$cmd); - -my $spec = nft_table_spec($table); -my $tmp = tempname(); -open_tempfile(my $fh, ">$tmp"); -print_tempfile($fh, "delete table $spec\n"); -close_tempfile($fh); - -my $out = backquote_logged("$cmd -c -f $tmp 2>&1"); -if (!$?) { - $out = backquote_logged("$cmd -f $tmp 2>&1"); -} -unlink_file($tmp); -if ($?) { - return "
$out
"; -} +my $meta = read_managed_metadata(); +delete($meta->{'tables'}->{managed_table_key($table)}); +write_managed_metadata($meta); return; } diff --git a/nftables/save_rule.cgi b/nftables/save_rule.cgi index e15ce745e..7efc803e2 100755 --- a/nftables/save_rule.cgi +++ b/nftables/save_rule.cgi @@ -5,7 +5,7 @@ require './nftables-lib.pl'; ## no critic use strict; use warnings; -our (%in, %text, %config); +our (%in, %text); ReadParse(); error_setup($text{'save_err'}); my @tables = get_nftables_save(); @@ -165,8 +165,7 @@ if ($in{'delete'}) { if ($cmd) { my $tmp = tempname(); open_tempfile(my $fh, ">$tmp"); - print_tempfile($fh, dump_nftables_save( - $config{'direct'} ? ($table) : @tables)); + print_tempfile($fh, dump_nftables_save(@tables)); close_tempfile($fh); my $out = backquote_logged("$cmd -c -f $tmp 2>&1"); unlink_file($tmp); diff --git a/nftables/setup.cgi b/nftables/setup.cgi index f0f6d09c0..c7809f7de 100644 --- a/nftables/setup.cgi +++ b/nftables/setup.cgi @@ -5,7 +5,7 @@ require './nftables-lib.pl'; ## no critic use strict; use warnings; -our (%in, %text, %config); +our (%in, %text); ReadParse(); if ($in{'action'} eq 'create') { my $type = $in{'type'}; @@ -27,11 +27,9 @@ if ($in{'action'} eq 'create') { if ($error) { error(text('setup_failed', $error)); } - if (!$config{'direct'}) { - $error = apply_restore(); - if ($error) { - error(text('setup_failed', $error)); - } + $error = apply_restore(); + if ($error) { + error(text('setup_failed', $error)); } webmin_log("setup", "create", $type); redirect("index.cgi"); diff --git a/nftables/t/run-tests.t b/nftables/t/run-tests.t index ace962a93..f56af5d1e 100755 --- a/nftables/t/run-tests.t +++ b/nftables/t/run-tests.t @@ -128,6 +128,14 @@ is($chain->{policy}, 'drop', 'chain policy'); my $ruleset_prio = "$bindir/rulesets/firewalld-priority.nft"; my @tables_prio = get_nftables_save($ruleset_prio); ok(@tables_prio == 1, 'firewalld priority table count'); +is($tables_prio[0]->{flags}, 'owner,persist', 'firewalld table flags'); +ok(table_is_externally_managed($tables_prio[0]), 'firewalld table 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 $fw_chain = $tables_prio[0]->{chains}->{filter_INPUT}; ok($fw_chain, 'firewalld priority chain present'); is($fw_chain->{type}, 'filter', 'firewalld priority chain type');