Files
webmin/nftables/edit_rule.cgi
Ilia Ross 12eff54277 Add nftables apply-needed header action
* Note: Track saved nftables configuration changes with Apache-style config/apply timestamp flags, expose the standard restart.cgi header action for themes, and use it as the single apply endpoint. The button expands to “Apply Changes” when saved rules need applying, while the existing Apply Configuration action now routes through restart.cgi and clears the pending state after a successful apply.
2026-05-03 13:59:58 +02:00

650 lines
20 KiB
Perl
Executable File

#!/usr/bin/perl
# edit_rule.cgi
# Display a form for creating or editing a rule
require './nftables-lib.pl'; ## no critic
use strict;
use warnings;
our (%in, %text, %config);
ReadParse();
my @tables = get_nftables_save();
my $table = $tables[$in{'table'}];
my $rule;
my $chain_def;
my $chain_hook;
my $action_sel;
my $proto_sel;
my $icmp_type;
my $log_enabled;
my $raw_extra = "";
my $ct_state_sel;
my $tcp_flags_sel;
my $advanced_open;
my $saddr_set;
my $daddr_set;
my $sport_set;
my $dport_set;
my $saddr_val;
my $daddr_val;
my $sport_val;
my $dport_val;
my @addr_set_opts;
my @port_set_opts;
my %set_families;
sub split_multi_value
{
my ($v) = @_;
return if (!defined($v) || $v eq '');
$v =~ s/^\s*\{//;
$v =~ s/\}\s*$//;
$v =~ s/^\s+//;
$v =~ s/\s+$//;
return if ($v eq '');
my @vals = split(/\s*,\s*/, $v);
@vals = grep { $_ ne '' } @vals;
return \@vals;
}
if ($in{'new'}) {
ui_print_header(undef, $text{'edit_title_new'}, "", "intro", 1, 1,
undef, restart_button());
$rule = { 'chain' => $in{'chain'} };
} else {
ui_print_header(undef, $text{'edit_title_edit'}, "", "intro", 1, 1,
undef, restart_button());
$rule = $table->{'rules'}->[$in{'idx'}];
}
if ($table && $rule->{'chain'}) {
$chain_def = $table->{'chains'}->{$rule->{'chain'}};
$chain_hook = $chain_def ? $chain_def->{'hook'} : undef;
}
if ($rule) {
if ($rule->{'exprs'} && ref($rule->{'exprs'}) eq 'ARRAY') {
my @raw = map { $_->{'text'} }
grep { $_->{'type'} && $_->{'type'} eq 'raw' }
@{$rule->{'exprs'}};
$raw_extra = join(" ", @raw);
}
if ($rule->{'jump'}) {
$action_sel = 'jump';
}
elsif ($rule->{'goto'}) {
$action_sel = 'goto';
}
else {
$action_sel = $rule->{'action'};
}
$action_sel ||= 'accept';
$proto_sel = $rule->{'proto'} || $rule->{'l4proto'};
if (!$proto_sel) {
$proto_sel = 'icmp' if ($rule->{'icmp_type'});
$proto_sel = 'icmpv6' if ($rule->{'icmpv6_type'});
}
$proto_sel ||= 'tcp' if ($in{'new'});
$icmp_type = $rule->{'icmp_type'} || $rule->{'icmpv6_type'};
$ct_state_sel = split_multi_value($rule->{'ct_state'});
$tcp_flags_sel = split_multi_value($rule->{'tcp_flags'});
$log_enabled = $rule->{'log'} || $rule->{'log_prefix'} || $rule->{'log_level'};
}
$saddr_set = set_name_from_value($rule->{'saddr'});
$daddr_set = set_name_from_value($rule->{'daddr'});
$sport_set = set_name_from_value($rule->{'sport'});
$dport_set = set_name_from_value($rule->{'dport'});
$saddr_val = $saddr_set ? "" : $rule->{'saddr'};
$daddr_val = $daddr_set ? "" : $rule->{'daddr'};
$sport_val = $sport_set ? "" : $rule->{'sport'};
$dport_val = $dport_set ? "" : $rule->{'dport'};
@addr_set_opts = ( [ "", $text{'edit_set_none'} ] );
@port_set_opts = ( [ "", $text{'edit_set_none'} ] );
my %addr_set_seen;
my %port_set_seen;
if ($table && $table->{'sets'} && ref($table->{'sets'}) eq 'HASH') {
foreach my $s (sort keys %{$table->{'sets'}}) {
my $set = $table->{'sets'}->{$s} || { };
my $label = $s;
$label .= " ($set->{'type'})" if ($set->{'type'});
my $kind = set_type_kind($set->{'type'});
if ($kind && $kind eq 'addr') {
push(@addr_set_opts, [ $s, $label ]);
$addr_set_seen{$s} = 1;
}
if ($kind && $kind eq 'port') {
push(@port_set_opts, [ $s, $label ]);
$port_set_seen{$s} = 1;
}
my $fam = set_type_family($set->{'type'});
$set_families{$s} = $fam if ($fam);
}
}
if ($saddr_set && !$addr_set_seen{$saddr_set}) {
push(@addr_set_opts, [ $saddr_set, $saddr_set ]);
}
if ($daddr_set && !$addr_set_seen{$daddr_set}) {
push(@addr_set_opts, [ $daddr_set, $daddr_set ]);
}
if ($sport_set && !$port_set_seen{$sport_set}) {
push(@port_set_opts, [ $sport_set, $sport_set ]);
}
if ($dport_set && !$port_set_seen{$dport_set}) {
push(@port_set_opts, [ $dport_set, $dport_set ]);
}
$advanced_open = 1 if ($action_sel && ($action_sel eq 'jump' || $action_sel eq 'goto'));
$advanced_open = 1 if ($rule && (
$rule->{'jump'} || $rule->{'goto'} ||
$rule->{'iif'} || $rule->{'oif'} ||
$icmp_type ||
$rule->{'ct_state'} ||
$rule->{'tcp_flags'} || $rule->{'tcp_flags_mask'} ||
$rule->{'limit_rate'} || $rule->{'limit_burst'} ||
$log_enabled ||
$rule->{'counter'}
));
my @icmp_types = qw(
echo-reply destination-unreachable source-quench redirect echo-request
router-advertisement router-solicitation time-exceeded parameter-problem
timestamp-request timestamp-reply info-request info-reply
address-mask-request address-mask-reply
);
my @icmpv6_types = qw(
destination-unreachable packet-too-big time-exceeded parameter-problem
echo-request echo-reply mld-listener-query mld-listener-report
mld-listener-done mld-listener-reduction nd-router-solicit
nd-router-advert nd-neighbor-solicit nd-neighbor-advert nd-redirect
router-renumbering ind-neighbor-solicit ind-neighbor-advert
mld2-listener-report
);
my %icmp_seen;
my @icmp_type_opts = ( [ "", $text{'edit_proto_any'} ] );
foreach my $t (@icmp_types, @icmpv6_types) {
next if ($icmp_seen{$t}++);
push(@icmp_type_opts, [ $t, $t ]);
}
my @ct_state_opts = (
[ "", $text{'edit_proto_any'} ],
map { [ $_, $_ ] } qw(invalid new established related untracked),
);
my @tcp_flags_opts = (
[ "", $text{'edit_proto_any'} ],
map { [ $_, $_ ] } qw(fin syn rst psh ack urg ecn cwr),
);
print ui_form_start("save_rule.cgi");
print ui_hidden("table", $in{'table'});
print ui_hidden("idx", $in{'idx'});
print ui_hidden("chain", $rule->{'chain'});
print ui_hidden("new", $in{'new'});
print ui_hidden("raw_extra", $raw_extra);
print ui_table_start($text{'edit_header'}, "width=100%", 2);
# Rule comment
print ui_table_row(hlink($text{'edit_comment'}, "comment"),
ui_textbox("comment", $rule->{'comment'}, 50));
# Action
print ui_table_row(hlink($text{'edit_action'}, "action"),
ui_select("action", $action_sel,
[
[ "accept", $text{'index_accept'} ],
[ "drop", $text{'index_drop'} ],
[ "reject", $text{'index_reject'} ],
[ "return", $text{'edit_return'} ],
[ "jump", $text{'edit_jump_action'} ],
[ "goto", $text{'edit_goto_action'} ],
]));
# Addresses
my $saddr_row = ui_textbox("saddr", $saddr_val, 30);
if (@addr_set_opts > 1) {
$saddr_row .= "<br>".text('edit_saddr_set',
ui_select("saddr_set", $saddr_set, \@addr_set_opts, 1, 0, 1));
}
print ui_table_row(hlink($text{'edit_saddr'}, "saddr"), $saddr_row);
my $daddr_row = ui_textbox("daddr", $daddr_val, 30);
if (@addr_set_opts > 1) {
$daddr_row .= "<br>".text('edit_daddr_set',
ui_select("daddr_set", $daddr_set, \@addr_set_opts, 1, 0, 1));
}
print ui_table_row(hlink($text{'edit_daddr'}, "daddr"), $daddr_row);
# Protocol
print ui_table_row(hlink($text{'edit_proto'}, "proto"),
ui_select("proto", $proto_sel,
[
[ "", $text{'edit_proto_any'} ],
[ "tcp", "TCP" ],
[ "udp", "UDP" ],
[ "icmp", "ICMP" ],
[ "icmpv6", "ICMPv6" ],
]));
# Ports
my $sport_row = ui_textbox("sport", $sport_val, 10);
if (@port_set_opts > 1) {
$sport_row .= "<br>".text('edit_sport_set',
ui_select("sport_set", $sport_set, \@port_set_opts, 1, 0, 1));
}
print ui_table_row(hlink($text{'edit_sport'}, "sport"), $sport_row);
my $dport_row = ui_textbox("dport", $dport_val, 10);
if (@port_set_opts > 1) {
$dport_row .= "<br>".text('edit_dport_set',
ui_select("dport_set", $dport_set, \@port_set_opts, 1, 0, 1));
}
print ui_table_row(hlink($text{'edit_dport'}, "dport"), $dport_row);
print ui_table_row(undef, ui_note($text{'edit_ports_note'}, 0), 2);
print ui_table_end();
print ui_hidden_table_start($text{'edit_advanced'}, "width=100%", 2,
"advanced", $advanced_open ? 1 : 0);
# Jump/Goto target chain
print ui_table_row(hlink($text{'edit_jump'}, "jump"),
ui_textbox("jump", $rule->{'jump'}, 20));
print ui_table_row(hlink($text{'edit_goto'}, "goto"),
ui_textbox("goto", $rule->{'goto'}, 20));
# Interfaces
if ($chain_hook && $chain_hook eq 'input') {
# Incoming interface
print ui_table_row(hlink($text{'edit_iif'}, "iif"),
interface_choice("iif", $rule->{'iif'}, $text{'edit_if_any'}));
}
elsif ($chain_hook && $chain_hook eq 'output') {
# Outgoing interface
print ui_table_row(hlink($text{'edit_oif'}, "oif"),
interface_choice("oif", $rule->{'oif'}, $text{'edit_if_any'}));
}
else {
# Forward or unknown chain - allow both
print ui_table_row(hlink($text{'edit_iif'}, "iif"),
interface_choice("iif", $rule->{'iif'}, $text{'edit_if_any'}));
print ui_table_row(hlink($text{'edit_oif'}, "oif"),
interface_choice("oif", $rule->{'oif'}, $text{'edit_if_any'}));
}
# ICMP type
print ui_table_row(hlink($text{'edit_icmp_type'}, "icmp_type"),
ui_select("icmp_type", $icmp_type, \@icmp_type_opts, 1, 0, 1));
# Conntrack state
print ui_table_row(hlink($text{'edit_ct_state'}, "ct_state"),
ui_select("ct_state", $ct_state_sel, \@ct_state_opts, 5, 1, 1));
# TCP flags
print ui_table_row(hlink($text{'edit_tcp_flags'}, "tcp_flags"),
ui_select("tcp_flags", $tcp_flags_sel, \@tcp_flags_opts, 8, 1, 1));
print ui_table_row(hlink($text{'edit_tcp_flags_mask'}, "tcp_flags_mask"),
ui_textbox("tcp_flags_mask", $rule->{'tcp_flags_mask'}, 20));
# Limit
print ui_table_row(hlink($text{'edit_limit_rate'}, "limit_rate"),
ui_textbox("limit_rate", $rule->{'limit_rate'}, 20));
print ui_table_row(hlink($text{'edit_limit_burst'}, "limit_burst"),
ui_textbox("limit_burst", $rule->{'limit_burst'}, 10));
# Log
my $log_row = ui_checkbox("log", 1, hlink($text{'edit_log_enable'}, "log_enable"), $log_enabled);
$log_row .= "<br>".text('edit_log_prefix', ui_textbox("log_prefix", $rule->{'log_prefix'}, 20));
$log_row .= " ".text('edit_log_level', ui_textbox("log_level", $rule->{'log_level'}, 10));
print ui_table_row($text{'edit_log'}, $log_row);
# Counter
print ui_table_row(hlink($text{'edit_counter'}, "counter"),
ui_checkbox("counter", 1, $text{'edit_counter_enable'}, $rule->{'counter'}));
print ui_hidden_table_end("advanced");
print ui_table_start($text{'edit_rule'}, "width=100%", 2);
# Raw rule (read-only unless edit direct is checked)
my $raw_controls = ui_checkbox("edit_direct", 1, $text{'edit_raw_rule_direct'}, 0);
my $raw_area = ui_textarea("raw_rule", $rule->{'text'}, 4, 60, undef, undef,
"readonly='true'");
print ui_table_row(hlink($text{'edit_raw_rule'}, "raw_rule"), $raw_controls."<br>".$raw_area,
undef, undef, ["data-column-span='all' data-column-locked='1'"]);
print ui_table_end();
my @buttons;
if ($in{'new'}) {
push(@buttons, [ undef, $text{'create'} ]);
} else {
push(@buttons, [ undef, $text{'save'} ]);
push(@buttons, [ 'delete', $text{'delete'} ]);
}
print ui_form_end(\@buttons);
sub js_array
{
my (@vals) = @_;
return "[".join(",", map {
my $v = $_;
$v =~ s/\\/\\\\/g;
$v =~ s/"/\\"/g;
"\"$v\"";
} @vals)."]";
}
sub js_object
{
my (%vals) = @_;
return "{".join(",", map {
my $k = $_;
my $v = $vals{$k};
$k =~ s/\\/\\\\/g;
$k =~ s/"/\\"/g;
$v =~ s/\\/\\\\/g if (defined($v));
$v =~ s/"/\\"/g if (defined($v));
"\"$k\":\"$v\"";
} sort keys %vals)."}";
}
my $icmp_js = js_array(@icmp_types);
my $icmpv6_js = js_array(@icmpv6_types);
my $icmp_any = $text{'edit_proto_any'};
$icmp_any =~ s/\\/\\\\/g;
$icmp_any =~ s/"/\\"/g;
my $set_fam_js = js_object(%set_families);
print "<script>\n";
print "(function() {\n";
print " var icmpTypes = $icmp_js;\n";
print " var icmpv6Types = $icmpv6_js;\n";
print " var icmpAnyLabel = \"$icmp_any\";\n";
print " var setFamilies = $set_fam_js;\n";
print <<'EOF';
function byName(name) {
var els = document.getElementsByName(name);
return els && els.length ? els[0] : null;
}
function val(name) {
var el = byName(name);
if (!el) return "";
if (el.tagName === "SELECT" && el.multiple) {
var vals = [];
var sawEmpty = false;
for (var i = 0; i < el.options.length; i++) {
var opt = el.options[i];
if (opt.selected) {
if (opt.value === "") sawEmpty = true;
else vals.push(opt.value);
}
}
if (vals.length && sawEmpty) {
for (var j = 0; j < el.options.length; j++) {
if (el.options[j].value === "") el.options[j].selected = false;
}
}
return vals.join(",");
}
if (el.type === "checkbox") {
return el.checked ? (el.value || "1") : "";
}
return el.value || "";
}
function ifaceVal(name) {
var v = val(name);
if (v === "other") {
return val(name + "_other");
}
return v;
}
function escapeNft(s) {
return s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
}
function isNumeric(s) {
return /^[0-9]+$/.test(s);
}
function guessFamily(addr) {
return addr.indexOf(":") >= 0 ? "ip6" : "ip";
}
function familyForSet(name) {
return setFamilies && setFamilies[name] ? setFamilies[name] : "";
}
function familyForValue(val) {
if (!val) return "";
if (val.charAt(0) === "@") {
var fam = familyForSet(val.substr(1));
if (fam) return fam;
}
return guessFamily(val);
}
function buildRule() {
var direct = byName("edit_direct");
if (direct && direct.checked) return;
var parts = [];
var iif = ifaceVal("iif");
if (iif) parts.push("iif \"" + escapeNft(iif) + "\"");
var oif = ifaceVal("oif");
if (oif) parts.push("oif \"" + escapeNft(oif) + "\"");
var saddrSet = val("saddr_set");
var saddr = val("saddr");
if (saddrSet) {
var sf = familyForSet(saddrSet) || guessFamily("@" + saddrSet);
parts.push(sf + " saddr @" + saddrSet);
} else if (saddr) {
parts.push(familyForValue(saddr) + " saddr " + saddr);
}
var daddrSet = val("daddr_set");
var daddr = val("daddr");
if (daddrSet) {
var df = familyForSet(daddrSet) || guessFamily("@" + daddrSet);
parts.push(df + " daddr @" + daddrSet);
} else if (daddr) {
parts.push(familyForValue(daddr) + " daddr " + daddr);
}
var proto = val("proto");
var sportSet = val("sport_set");
var dportSet = val("dport_set");
var sport = sportSet ? ("@" + sportSet) : val("sport");
var dport = dportSet ? ("@" + dportSet) : val("dport");
var icmpType = val("icmp_type");
if (!proto && (sport || dport)) {
proto = "tcp";
}
var l4proto = "";
var portProto = "";
if (proto && (proto === "tcp" || proto === "udp")) {
portProto = proto;
if (!sport && !dport) {
l4proto = proto;
}
}
else if (proto) {
l4proto = proto;
}
if (l4proto) {
parts.push("meta l4proto " + l4proto);
}
if (sport && portProto) parts.push(portProto + " sport " + sport);
if (dport && portProto) parts.push(portProto + " dport " + dport);
if (proto === "icmp" && icmpType) parts.push("icmp type " + icmpType);
if (proto === "icmpv6" && icmpType) parts.push("icmpv6 type " + icmpType);
if (!proto && icmpType) {
var inIcmp = icmpTypes.indexOf(icmpType) >= 0;
var inIcmpv6 = icmpv6Types.indexOf(icmpType) >= 0;
if (inIcmpv6 && !inIcmp) {
parts.push("meta l4proto icmpv6");
parts.push("icmpv6 type " + icmpType);
} else {
parts.push("meta l4proto icmp");
parts.push("icmp type " + icmpType);
}
}
var tcpFlags = val("tcp_flags");
var tcpMask = val("tcp_flags_mask");
if (tcpFlags) {
if (tcpMask) parts.push("tcp flags & " + tcpMask + " == " + tcpFlags);
else parts.push("tcp flags " + tcpFlags);
}
var ctState = val("ct_state");
if (ctState) parts.push("ct state " + ctState);
var limitRate = val("limit_rate");
var limitBurst = val("limit_burst");
if (limitRate) {
var lim = "limit rate " + limitRate;
if (limitBurst) {
lim += " burst " + limitBurst;
if (isNumeric(limitBurst)) lim += " packets";
}
parts.push(lim);
}
var logBox = byName("log");
var logEnabled = logBox && logBox.checked;
var logPrefix = val("log_prefix");
var logLevel = val("log_level");
if (logEnabled || logPrefix || logLevel) {
var lp = ["log"];
if (logPrefix) lp.push("prefix \"" + escapeNft(logPrefix) + "\"");
if (logLevel) lp.push("level " + logLevel);
parts.push(lp.join(" "));
}
var counter = byName("counter");
if (counter && counter.checked) parts.push("counter");
var action = val("action");
var jump = val("jump");
var go = val("goto");
if (action === "jump" && jump) parts.push("jump " + jump);
else if (action === "goto" && go) parts.push("goto " + go);
else if (action && action !== "jump" && action !== "goto") parts.push(action);
var comment = val("comment");
if (comment) parts.push("comment \"" + escapeNft(comment) + "\"");
var extra = val("raw_extra");
if (extra) parts.push(extra);
var raw = parts.join(" ").replace(/^\s+|\s+$/g, "");
var rawEl = byName("raw_rule");
if (rawEl) rawEl.value = raw;
}
function toggleDirect() {
var direct = byName("edit_direct");
var on = direct && direct.checked;
var form = direct ? direct.form : document.forms[0];
if (!form) return;
var els = form.querySelectorAll("input, select, textarea");
for (var i = 0; i < els.length; i++) {
var el = els[i];
if (el.name === "edit_direct" || el.name === "raw_rule") continue;
if (el.type === "hidden" || el.type === "submit" || el.type === "button") continue;
el.disabled = on;
}
var rawEl = byName("raw_rule");
if (rawEl) rawEl.readOnly = !on;
if (!on) buildRule();
}
function uniqList(list) {
var seen = {};
var out = [];
for (var i = 0; i < list.length; i++) {
var v = list[i];
if (seen[v]) continue;
seen[v] = true;
out.push(v);
}
return out;
}
function updateIcmpTypes() {
var el = byName("icmp_type");
if (!el) return;
var proto = val("proto");
var current = el.value || "";
var list;
if (proto === "icmp") list = icmpTypes;
else if (proto === "icmpv6") list = icmpv6Types;
else list = uniqList(icmpTypes.concat(icmpv6Types));
while (el.options.length) {
el.remove(0);
}
var optAny = document.createElement("option");
optAny.value = "";
optAny.text = icmpAnyLabel;
el.add(optAny);
for (var i = 0; i < list.length; i++) {
var opt = document.createElement("option");
opt.value = list[i];
opt.text = list[i];
el.add(opt);
}
if (current && list.indexOf(current) >= 0) el.value = current;
else el.value = "";
}
function maybeSetProtoFromIcmp() {
var protoEl = byName("proto");
if (!protoEl || protoEl.value) return;
var t = val("icmp_type");
if (!t) return;
var inIcmp = icmpTypes.indexOf(t) >= 0;
var inIcmpv6 = icmpv6Types.indexOf(t) >= 0;
if (inIcmp && !inIcmpv6) protoEl.value = "icmp";
else if (inIcmpv6 && !inIcmp) protoEl.value = "icmpv6";
if (protoEl.value) updateIcmpTypes();
}
function bind() {
var direct = byName("edit_direct");
var form = direct ? direct.form : document.forms[0];
if (!form) return;
var els = form.querySelectorAll("input, select, textarea");
for (var i = 0; i < els.length; i++) {
var el = els[i];
if (el.name === "raw_rule") continue;
if (el.name === "edit_direct") {
el.addEventListener("change", toggleDirect);
continue;
}
if (el.name === "proto") {
el.addEventListener("change", function() {
updateIcmpTypes();
buildRule();
});
continue;
}
if (el.name === "icmp_type") {
el.addEventListener("change", function() {
maybeSetProtoFromIcmp();
buildRule();
});
continue;
}
el.addEventListener("input", buildRule);
el.addEventListener("change", buildRule);
}
updateIcmpTypes();
toggleDirect();
buildRule();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", bind);
} else {
bind();
}
})();
</script>
EOF
ui_print_footer("index.cgi?table=$in{'table'}", $text{'index_return'});