Add firewall address-set (ipset) support

Add firewall address-sets: named sets of IP addresses and networks,
usable as zone sources for per-IP access control, issue #1189.  Static
entries are part of the configuration; dynamic entries are managed at
runtime with new add/remove/flush actions, from the CLI or over
NETCONF/RESTCONF.  Dynamic entries survive firewall configuration
changes but are not saved to the configuration.  Sets with a timeout
expire their entries automatically.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2026-07-03 08:59:37 +02:00
parent ca1d40cf7a
commit 56ea7faa7c
26 changed files with 1979 additions and 59 deletions
+30
View File
@@ -103,6 +103,20 @@ panic_off()
logger -p user.emerg "LOCKDOWN MODE DEACTIVATED - Normal network operation restored"
}
ipset_call()
{
method=$1
name=$2
entry=$3
if ! output=$(gdbus call --system --dest "$DEST" --object-path "$OBJECT" \
--method "$INTERFACE.ipset.$method" "$name" "$entry" 2>&1); then
logger -t firewall -p daemon.err "ipset $method $name $entry: $output"
print "Error: $output" >&2
return 1
fi
}
panic_status()
{
if is_panic_enabled; then
@@ -294,6 +308,7 @@ OPTIONS:
COMMANDS:
reload Reload firewall configuration
panic OPERATION Emergency panic mode: <on | off | status>
ipset CMD SET ENTRY Runtime ipset operation: <add | del> SET ENTRY
show Show comprehensive firewall status and configuration
help Show this help message
@@ -386,6 +401,21 @@ main()
;;
esac
;;
ipset)
case "${2:-}" in
add)
ipset_call addEntry "$3" "$4"
;;
del)
ipset_call removeEntry "$3" "$4"
;;
*)
echo "Error: Invalid ipset operation '$2'" >&2
echo "Use: $0 ipset {add|del} SET ENTRY" >&2
exit 1
;;
esac
;;
show)
show_status
;;
+370 -1
View File
@@ -6,6 +6,7 @@
#include <unistd.h>
#include <errno.h>
#include <dirent.h>
#include <arpa/inet.h>
#include <srx/common.h>
#include <srx/lyx.h>
@@ -24,6 +25,11 @@
#define FIREWALLD_ZONES_DIR FIREWALLD_DIR_NEXT "/zones"
#define FIREWALLD_SERVICES_DIR FIREWALLD_DIR_NEXT "/services"
#define FIREWALLD_POLICIES_DIR FIREWALLD_DIR_NEXT "/policies"
#define FIREWALLD_IPSETS_DIR FIREWALLD_DIR_NEXT "/ipsets"
#define IPSETS_ACTIVE_DIR FIREWALLD_DIR "/ipsets"
#define ADDRSET_RUNDIR "/run/confd/address-sets"
#define ENTRY_STRLEN 64 /* worst-case ip-prefix + margin */
static struct {
const char *yang;
@@ -64,6 +70,148 @@ static const char *policy_action_to_target(const char *action)
return policy_action_map[0].yang;
}
struct prefix {
int af;
uint8_t addr[16];
int len;
};
static int prefix_parse(const char *str, struct prefix *p)
{
char buf[ENTRY_STRLEN];
char *sep;
strlcpy(buf, str, sizeof(buf));
sep = strchr(buf, '/');
if (sep) {
*sep++ = 0;
p->len = atoi(sep);
} else {
p->len = -1;
}
if (inet_pton(AF_INET, buf, p->addr) == 1) {
p->af = AF_INET;
if (p->len < 0)
p->len = 32;
return 0;
}
if (inet_pton(AF_INET6, buf, p->addr) == 1) {
p->af = AF_INET6;
if (p->len < 0)
p->len = 128;
return 0;
}
return -1;
}
static bool prefix_overlap(const char *a, const char *b)
{
struct prefix pa, pb;
int len, i;
if (prefix_parse(a, &pa) || prefix_parse(b, &pb) || pa.af != pb.af)
return false;
len = pa.len < pb.len ? pa.len : pb.len;
for (i = 0; i < len / 8; i++) {
if (pa.addr[i] != pb.addr[i])
return false;
}
if (len % 8) {
uint8_t mask = 0xff << (8 - len % 8);
if ((pa.addr[i] & mask) != (pb.addr[i] & mask))
return false;
}
return true;
}
static bool shadow_has(const char *name, const char *entry)
{
char line[ENTRY_STRLEN];
bool found = false;
FILE *fp;
fp = fopenf("r", ADDRSET_RUNDIR "/%s", name);
if (!fp)
return false;
while (fgets(line, sizeof(line), fp)) {
chomp(line);
if (!strcmp(line, entry)) {
found = true;
break;
}
}
fclose(fp);
return found;
}
static int shadow_add(const char *name, const char *entry)
{
char line[ENTRY_STRLEN];
FILE *fp;
if (fmkpath(0755, ADDRSET_RUNDIR) && errno != EEXIST) {
ERRNO("Failed creating " ADDRSET_RUNDIR);
return -1;
}
fp = fopenf("a+", ADDRSET_RUNDIR "/%s", name);
if (!fp) {
ERRNO("Failed recording dynamic entry for address-set %s", name);
return -1;
}
while (fgets(line, sizeof(line), fp)) {
chomp(line);
if (!strcmp(line, entry)) {
fclose(fp);
return 0;
}
}
fprintf(fp, "%s\n", entry);
fclose(fp);
return 0;
}
static int shadow_del(const char *name, const char *entry)
{
char curr[sizeof(ADDRSET_RUNDIR) + ENTRY_STRLEN], next[sizeof(curr) + 1];
char line[ENTRY_STRLEN];
FILE *in, *out;
snprintf(curr, sizeof(curr), ADDRSET_RUNDIR "/%s", name);
snprintf(next, sizeof(next), "%s+", curr);
in = fopen(curr, "r");
if (!in)
return -1;
out = fopen(next, "w");
if (!out) {
fclose(in);
return -1;
}
while (fgets(line, sizeof(line), in)) {
chomp(line);
if (!strcmp(line, entry))
continue;
fprintf(out, "%s\n", line);
}
fclose(in);
fclose(out);
return rename(next, curr);
}
static void mark_interfaces_used(struct lyd_node *cfg, char **ifaces)
{
struct lyd_node *node;
@@ -177,6 +325,9 @@ static int generate_zone(struct lyd_node *cfg, const char *name, char **ifaces)
LYX_LIST_FOR_EACH(lyd_child(cfg), node, "network")
fprintf(fp, " <source address=\"%s\"/>\n", lyd_get_value(node));
LYX_LIST_FOR_EACH(lyd_child(cfg), node, "address-set")
fprintf(fp, " <source ipset=\"%s\"/>\n", lyd_get_value(node));
LYX_LIST_FOR_EACH(lyd_child(cfg), node, "service")
fprintf(fp, " <service name=\"%s\"/>\n", lyd_get_value(node));
@@ -219,6 +370,85 @@ static int generate_zone(struct lyd_node *cfg, const char *name, char **ifaces)
return close_file(fp);
}
/*
* Dynamic entries, added at runtime with the add action, are folded
* into the generated ipset as regular entries so they survive the
* firewalld reload triggered by configuration changes. Entries that
* overlap new static configuration are dropped -- config wins, and
* nftables refuses overlapping elements in interval sets.
*/
static void merge_dynamic(FILE *fp, struct lyd_node *cfg, const char *name)
{
char line[ENTRY_STRLEN];
FILE *sf;
sf = fopenf("r", ADDRSET_RUNDIR "/%s", name);
if (!sf)
return;
while (fgets(line, sizeof(line), sf)) {
struct lyd_node *node;
bool skip = false;
chomp(line);
if (!line[0])
continue;
LYX_LIST_FOR_EACH(lyd_child(cfg), node, "entry") {
if (prefix_overlap(line, lyd_get_value(node))) {
skip = true;
break;
}
}
if (skip) {
NOTE("address-set %s: dropping dynamic entry %s, overlaps static entry",
name, line);
continue;
}
fprintf(fp, " <entry>%s</entry>\n", line);
}
fclose(sf);
}
static int generate_ipset(struct lyd_node *cfg, const char *name)
{
const char *family, *timeout, *desc;
struct lyd_node *node;
FILE *fp;
fp = open_file(FIREWALLD_IPSETS_DIR, name);
if (!fp)
return SR_ERR_SYS;
desc = lydx_get_cattr(cfg, "description");
family = lydx_get_cattr(cfg, "family");
timeout = lydx_get_cattr(cfg, "timeout");
fprintf(fp, "<ipset type=\"hash:net\">\n");
fprintf(fp, " <short>%s</short>\n", name);
if (desc)
fprintf(fp, " <description>%s</description>\n", desc);
fprintf(fp, " <option name=\"family\" value=\"%s\"/>\n",
family && !strcmp(family, "ipv6") ? "inet6" : "inet");
if (timeout)
fprintf(fp, " <option name=\"timeout\" value=\"%s\"/>\n", timeout);
LYX_LIST_FOR_EACH(lyd_child(cfg), node, "entry")
fprintf(fp, " <entry>%s</entry>\n", lyd_get_value(node));
if (!timeout)
merge_dynamic(fp, cfg, name);
fprintf(fp, "</ipset>\n");
return close_file(fp);
}
static int generate_service(struct lyd_node *cfg, const char *name)
{
const char *desc;
@@ -511,9 +741,17 @@ int firewall_change(sr_session_ctx_t *session, struct lyd_node *config, struct l
if (!fisdir(FIREWALLD_DIR_NEXT)) {
/* Firewall is disabled */
finit_disable("firewalld");
rmrf(ADDRSET_RUNDIR);
return SR_ERR_OK;
}
/* Drop dynamic state of deleted address-sets */
clist = lydx_get_descendant(diff, "firewall", "address-set", NULL);
LYX_LIST_FOR_EACH(clist, cnode, "address-set") {
if (lydx_get_op(cnode) == LYDX_OP_DELETE)
erasef(ADDRSET_RUNDIR "/%s", lydx_get_cattr(cnode, "name"));
}
/* Firewall is enabled, roll in new configuration */
rmrf(FIREWALLD_DIR);
if (rename(FIREWALLD_DIR_NEXT, FIREWALLD_DIR)) {
@@ -551,7 +789,8 @@ int firewall_change(sr_session_ctx_t *session, struct lyd_node *config, struct l
if (fmkpath(0755, FIREWALLD_DIR_NEXT) ||
fmkpath(0755, FIREWALLD_ZONES_DIR) ||
fmkpath(0755, FIREWALLD_SERVICES_DIR) ||
fmkpath(0755, FIREWALLD_POLICIES_DIR)) {
fmkpath(0755, FIREWALLD_POLICIES_DIR) ||
fmkpath(0755, FIREWALLD_IPSETS_DIR)) {
ERRNO("Failed creating " FIREWALLD_DIR_NEXT " directory structure");
err = SR_ERR_SYS;
goto done;
@@ -622,6 +861,11 @@ int firewall_change(sr_session_ctx_t *session, struct lyd_node *config, struct l
LYX_LIST_FOR_EACH(clist, cnode, "service")
generate_service(cnode, lydx_get_cattr(cnode, "name"));
/* Regenerate all address-sets, incl. dynamic entries */
clist = lydx_get_descendant(tree, "firewall", "address-set", NULL);
LYX_LIST_FOR_EACH(clist, cnode, "address-set")
generate_ipset(cnode, lydx_get_cattr(cnode, "name"));
/* Regenerate all policies with sequential priority allocation */
clist = lydx_get_descendant(tree, "firewall", "policy", NULL);
LYX_LIST_FOR_EACH(clist, cnode, "policy") {
@@ -701,6 +945,128 @@ static int cand(sr_session_ctx_t *session, uint32_t sub_id, const char *module,
return SR_ERR_OK;
}
static int addrset_flush(const char *name)
{
char line[ENTRY_STRLEN];
FILE *fp;
fp = fopenf("r", ADDRSET_RUNDIR "/%s", name);
if (!fp)
return SR_ERR_OK; /* no dynamic entries */
while (fgets(line, sizeof(line), fp)) {
chomp(line);
if (!line[0])
continue;
if (systemf("firewall ipset del %s %s", name, line))
ERROR("address-set %s: failed removing dynamic entry %s", name, line);
}
fclose(fp);
erasef(ADDRSET_RUNDIR "/%s", name);
return SR_ERR_OK;
}
/*
* Canonicalize like firewalld: host entries lose their prefix length,
* IPv6 is compressed. Keeps shadow file lookups exact-match.
*/
static const char *entry_canon(const char *entry, char *buf, size_t len)
{
char addr[INET6_ADDRSTRLEN];
struct prefix p;
if (prefix_parse(entry, &p) || !inet_ntop(p.af, p.addr, addr, sizeof(addr)))
return entry;
if ((p.af == AF_INET && p.len == 32) || (p.af == AF_INET6 && p.len == 128))
snprintf(buf, len, "%s", addr);
else
snprintf(buf, len, "%s/%d", addr, p.len);
return buf;
}
static int addrset(sr_session_ctx_t *session, uint32_t sub_id, const char *xpath,
const sr_val_t *input, const size_t input_cnt, sr_event_t event,
uint32_t request_id, sr_val_t **output, size_t *output_cnt, void *priv)
{
char buf[strlen(xpath) + 1], canon[ENTRY_STRLEN], name[65];
const char *cmd = (const char *)priv;
const char *entry = NULL;
sr_xpath_ctx_t state = {};
sr_session_ctx_t *cfg;
char *val;
bool timeout;
/* /infix-firewall:firewall/address-set[name='allowed']/add */
strlcpy(buf, xpath, sizeof(buf));
val = sr_xpath_key_value(buf, "address-set", "name", &state);
if (!val)
return SR_ERR_INTERNAL;
strlcpy(name, val, sizeof(name));
if (input_cnt > 0)
entry = entry_canon(input[0].data.string_val, canon, sizeof(canon));
if (sr_session_start(sr_session_get_connection(session), SR_DS_RUNNING, &cfg))
return SR_ERR_INTERNAL;
val = srx_get_str(cfg, XPATH "/address-set[name='%s']/name", name);
if (!val) {
sr_session_stop(cfg);
sr_session_set_error_message(session, "No such address-set: %s", name);
return SR_ERR_INVAL_ARG;
}
free(val);
val = srx_get_str(cfg, XPATH "/address-set[name='%s']/timeout", name);
timeout = val != NULL;
free(val);
sr_session_stop(cfg);
DEBUG("address-set %s: %s %s", name, cmd, entry ?: "");
if (!strcmp(cmd, "add")) {
if (systemf("firewall ipset add %s %s", name, entry)) {
sr_session_set_error_message(session, "Failed adding %s to address-set %s, "
"see log for details", entry, name);
return SR_ERR_OPERATION_FAILED;
}
if (!timeout)
shadow_add(name, entry);
return SR_ERR_OK;
}
if (timeout) {
sr_session_set_error_message(session, "Entries in address-set %s expire on "
"their own (timeout set)", name);
return SR_ERR_UNSUPPORTED;
}
if (!strcmp(cmd, "flush"))
return addrset_flush(name);
/* remove */
if (!shadow_has(name, entry)) {
sr_session_set_error_message(session, "%s is not a dynamic entry in address-set %s, "
"static entries are removed via configuration", entry, name);
return SR_ERR_INVAL_ARG;
}
if (systemf("firewall ipset del %s %s", name, entry)) {
sr_session_set_error_message(session, "Failed removing %s from address-set %s, "
"see log for details", entry, name);
return SR_ERR_OPERATION_FAILED;
}
shadow_del(name, entry);
return SR_ERR_OK;
}
static int lockdown(sr_session_ctx_t *session, uint32_t sub_id, const char *xpath,
const sr_val_t *input, const size_t input_cnt, sr_event_t event,
uint32_t request_id, sr_val_t **output, size_t *output_cnt, void *priv)
@@ -723,6 +1089,9 @@ int firewall_rpc_init(struct confd *confd)
int rc;
REGISTER_RPC(confd->session, XPATH "/lockdown-mode", lockdown, NULL, &confd->sub);
REGISTER_RPC(confd->session, XPATH "/address-set/add", addrset, "add", &confd->sub);
REGISTER_RPC(confd->session, XPATH "/address-set/remove", addrset, "remove", &confd->sub);
REGISTER_RPC(confd->session, XPATH "/address-set/flush", addrset, "flush", &confd->sub);
return SR_ERR_OK;
fail:
+1 -1
View File
@@ -38,7 +38,7 @@ MODULES=(
"infix-dhcp-client@2025-11-09.yang"
"infix-dhcpv6-client@2025-11-09.yang"
"infix-dhcp-server@2025-10-28.yang"
"infix-firewall@2025-04-26.yang"
"infix-firewall@2026-07-02.yang"
"infix-firewall-services@2025-04-26.yang"
"infix-firewall-icmp-types@2025-04-26.yang"
"infix-meta@2025-12-10.yang"
+153
View File
@@ -27,6 +27,13 @@ module infix-firewall {
contact "kernelkit@googlegroups.com";
description "Zone-based firewall inspired by firewalld concepts.";
revision 2026-07-02 {
description "Add address-set support: named sets of IP addresses and
networks, usable as zone sources. Includes add/remove/flush
actions for managing dynamic entries at runtime.";
reference "internal";
}
revision 2025-04-26 {
description "Initial revision.";
reference "internal";
@@ -95,6 +102,14 @@ module infix-firewall {
description "Action for traffic that does not match any specific service or port entry.";
}
typedef set-entry {
description "A member of an address-set: a host address or a network prefix.";
type union {
type inet:ip-address;
type inet:ip-prefix;
}
}
typedef protocol-type {
description "Network protocols supported for services and port definitions.";
@@ -220,6 +235,21 @@ module infix-firewall {
type inet:ip-prefix;
}
leaf-list address-set {
description "Address sets whose members are sources for this zone.
Like 'network', but members can also be added and removed
at runtime using the address-set add/remove actions.
Source matching takes precedence over interface matching,
so a member of an address set is classified into this zone
even when its traffic arrives on an interface assigned to
another zone.";
type leafref {
path "../../address-set/name";
}
}
leaf-list service {
description "Services allowed from this zone to HOST (INPUT chain only).
@@ -460,6 +490,129 @@ module infix-firewall {
}
}
list address-set {
description "Named sets of IP addresses and networks, usable as zone sources.
Entries configured here are static: part of the configuration
and restored at boot. Entries can also be added and removed
at runtime with the add/remove actions. Dynamic entries are
not saved to the configuration and are lost on reboot.
The 'current' list shows the actual contents of the set,
both static and dynamic entries.";
key "name";
must "not(timeout) or count(entry) = 0" {
error-message "Address sets with a timeout cannot have static entries";
}
leaf name {
description "Name of the address set.";
type ident;
}
leaf description {
description "Free-form description of the address set.";
type string {
pattern '[^\p{Cc}<>&]*';
}
}
leaf family {
description "Address family of the set. A set holds either IPv4 or
IPv6 entries, never both.";
type enumeration {
enum ipv4;
enum ipv6;
}
default ipv4;
}
leaf timeout {
description "Lifetime for dynamic entries, making the set dynamic-only.
Entries added with the add action expire after this many
seconds. Static entries cannot be configured, and dynamic
entries cannot be removed or flushed, they expire on their
own. Changes to the firewall configuration flush all
entries from timeout sets early.";
type uint32 {
range "1..max";
}
units "seconds";
}
leaf-list entry {
description "Static entries, part of the configuration.";
type set-entry;
must "not(contains(., ':')) or ../family = 'ipv6'" {
error-message "IPv6 entry in an IPv4 address-set";
}
must "contains(., ':') or ../family = 'ipv4'" {
error-message "IPv4 entry in an IPv6 address-set";
}
}
list current {
description "Entries currently active in the set.";
config false;
key "entry";
leaf entry {
description "Set member.";
type set-entry;
}
leaf dynamic {
description "Entry was added at runtime, not part of the configuration.";
type boolean;
}
leaf expires {
description "Seconds until this entry expires and is removed.";
type uint32;
units "seconds";
}
}
action add {
description "Add a dynamic entry to the set.
The entry takes effect immediately but is not saved to the
configuration, i.e., it is lost on reboot. For permanent
entries, use the 'entry' list instead.";
input {
leaf entry {
description "Host address or network prefix to add.";
type set-entry;
mandatory true;
}
}
}
action remove {
description "Remove a dynamic entry from the set.
Static entries cannot be removed with this action, only
by changing the configuration. Entries in timeout sets
cannot be removed, they expire on their own.";
input {
leaf entry {
description "Host address or network prefix to remove.";
type set-entry;
mandatory true;
}
}
}
action flush {
description "Remove all dynamic entries from the set. Static entries
remain. Not supported for timeout sets, their entries
expire on their own.";
}
}
leaf lockdown {
description "Current state of emergency lockdown mode.";
config false;
+60
View File
@@ -297,6 +297,64 @@ int infix_firewall_services(kcontext_t *ctx)
"| sed 's/^(//; s/,)$//' | tr \"'\" '\"' | jq -r '.[]' 2>/dev/null");
}
/*
* Completion function for firewall address-sets (ipsets).
* D-Bus returns variant format: (['set1', 'set2'],)
*/
int infix_firewall_addrsets(kcontext_t *ctx)
{
(void)ctx;
return firewall_dbus_completion("ipset", "getIPSets",
"sed 's/^(//; s/,)$//' | tr \"'\" '\"' | jq -r '.[]' 2>/dev/null");
}
/*
* Send address-set action (add/remove/flush) using the rpc tool, as the
* logged-in user to honor NACM. srp_rpc from klish-plugin-sysrepo only
* handles fixed xpaths, these actions live on list entries.
*/
int infix_firewall_addrset_action(kcontext_t *ctx)
{
kpargv_t *pargv = kcontext_pargv(ctx);
const char *name, *op;
char xpath[128];
char *argv[8];
kparg_t *parg;
int i = 0;
parg = kpargv_find(pargv, "setname");
if (!parg) {
fprintf(stderr, ERRMSG "missing address-set name\n");
return -1;
}
name = kparg_value(parg);
if (kpargv_find(pargv, "add"))
op = "add";
else if (kpargv_find(pargv, "remove"))
op = "remove";
else
op = "flush";
snprintf(xpath, sizeof(xpath),
"/infix-firewall:firewall/address-set[name='%s']/%s", name, op);
argv[i++] = "doas";
argv[i++] = "-u";
argv[i++] = (char *)cd_home(ctx);
argv[i++] = "rpc";
argv[i++] = xpath;
parg = kpargv_find(pargv, "entry");
if (parg) {
argv[i++] = "entry";
argv[i++] = (char *)kparg_value(parg);
}
argv[i] = NULL;
return run(argv);
}
int infix_copy(kcontext_t *ctx)
{
kpargv_t *pargv = kcontext_pargv(ctx);
@@ -689,6 +747,8 @@ int kplugin_infix_init(kcontext_t *ctx)
kplugin_add_syms(plugin, ksym_new("firewall_zones", infix_firewall_zones));
kplugin_add_syms(plugin, ksym_new("firewall_policies", infix_firewall_policies));
kplugin_add_syms(plugin, ksym_new("firewall_services", infix_firewall_services));
kplugin_add_syms(plugin, ksym_new("firewall_addrsets", infix_firewall_addrsets));
kplugin_add_syms(plugin, ksym_new("firewall_addrset_action", infix_firewall_addrset_action));
kplugin_add_syms(plugin, ksym_new("set_boot_order", infix_set_boot_order));
kplugin_add_syms(plugin, ksym_new("shell", infix_shell));
kplugin_add_syms(plugin, ksym_new("ssh_connect", infix_ssh_connect));
+31
View File
@@ -134,6 +134,13 @@
<ACTION sym="STRING"/>
</PTYPE>
<PTYPE name="FIREWALL_ADDRSETS">
<COMPL>
<ACTION sym="firewall_addrsets@infix"/>
</COMPL>
<ACTION sym="STRING"/>
</PTYPE>
<PTYPE name="BOOT_TARGET">
<COMPL>
<ACTION sym="boot_targets@infix"/>
@@ -811,6 +818,14 @@ echo "Public: $pub"
copy operational -x /infix-firewall:firewall | /usr/libexec/statd/cli-pretty show-firewall-service "$KLISH_PARAM_name" |pager
</ACTION>
</COMMAND>
<COMMAND name="address-set" help="Show firewall address-sets">
<SWITCH name="optional" min="0">
<PARAM name="name" ptype="/FIREWALL_ADDRSETS" help="Address-set name"/>
</SWITCH>
<ACTION sym="script" in="tty" out="tty" interrupt="true">
copy operational -x /infix-firewall:firewall | /usr/libexec/statd/cli-pretty show-firewall-address-set "$KLISH_PARAM_name" |pager
</ACTION>
</COMMAND>
</SWITCH>
<ACTION sym="script" in="tty" out="tty" interrupt="true">
copy operational -x /infix-firewall:firewall | /usr/libexec/statd/cli-pretty show-firewall |pager
@@ -826,6 +841,22 @@ echo "Public: $pub"
</COMMAND>
<COMMAND name="firewall" help="Control the firewall" mode="switch">
<COMMAND name="address-set" help="Manage dynamic address-set entries">
<PARAM name="setname" ptype="/FIREWALL_ADDRSETS" help="Address-set name"/>
<SWITCH name="op">
<COMMAND name="add" help="Add dynamic entry, not saved to configuration">
<PARAM name="entry" ptype="/STRING" help="IP address or network prefix"/>
<ACTION sym="firewall_addrset_action@infix"/>
</COMMAND>
<COMMAND name="remove" help="Remove dynamic entry">
<PARAM name="entry" ptype="/STRING" help="IP address or network prefix"/>
<ACTION sym="firewall_addrset_action@infix"/>
</COMMAND>
<COMMAND name="flush" help="Remove all dynamic entries">
<ACTION sym="firewall_addrset_action@infix"/>
</COMMAND>
</SWITCH>
</COMMAND>
<COMMAND name="lockdown" help="Emergency lockdown mode">
<PARAM name="operation" ptype="/STRING" help="Lockdown commands.">
<COMPL>
+103 -11
View File
@@ -4149,6 +4149,7 @@ def show_firewall(json):
# Create tables
zone_table = firewall_zone_table(json)
policy_table = firewall_policy_table(json)
address_set_table = firewall_address_set_table(json)
# Add zone table
if zone_table:
@@ -4156,6 +4157,11 @@ def show_firewall(json):
canvas.add_table(zone_table)
canvas.add_spacing()
if address_set_table:
canvas.add_title("Address Sets")
canvas.add_table(address_set_table)
canvas.add_spacing()
# Add policy table
if policy_table:
canvas.add_title("Policies")
@@ -4447,13 +4453,13 @@ def firewall_matrix(fw, width=None):
zones = fw.get('zone', [])
policies = fw.get('policy', [])
# Build zone list - include zones with interfaces OR networks
# Build zone list - include zones with interfaces, networks, or address-set sources
zone_names = []
for z in zones:
interfaces = z.get('interface', [])
networks = z.get('network', [])
# Include if zone has interfaces OR networks (non-empty lists)
if interfaces or networks:
address_sets = z.get('address-set', [])
if interfaces or networks or address_sets:
zone_names.append(z['name'])
# Always add the implicit HOST zone
@@ -4558,6 +4564,7 @@ def firewall_zone_table(json):
Column('NAME', flexible=True),
Column('TYPE'),
Column('DATA', flexible=True),
Column('ADDR SET'),
Column('ALLOWED HOST SERVICES', flexible=True)
])
@@ -4566,6 +4573,7 @@ def firewall_zone_table(json):
action = zone.get('action', 'reject')
interface_list = zone.get('interface', [])
network_list = zone.get('network', [])
address_set_list = zone.get('address-set', [])
port_forwards = zone.get('port-forward', [])
services = zone.get('service', [])
@@ -4586,7 +4594,7 @@ def firewall_zone_table(json):
if interface_list:
interfaces = compress_interface_list(interface_list)
config_lines.append(("iif", interfaces))
else:
elif not network_list and not address_set_list and not port_forwards:
config_lines.append(("iif", "(none)"))
# Networks
@@ -4599,14 +4607,21 @@ def firewall_zone_table(json):
pf_display = format_port_forwards(port_forwards)
config_lines.append(("fwd", pf_display))
# Add first line with zone name and services
if config_lines:
first_type, first_data = config_lines[0]
zone_table.row(locked, name, first_type, first_data, services_display)
addr_rows = address_set_list[:] if address_set_list else ["(none)"]
row_count = max(len(config_lines), len(addr_rows))
if not config_lines:
config_lines = [("", "")]
# Add additional configuration lines as separate rows
for config_type, config_data in config_lines[1:]:
zone_table.row('', '', config_type, config_data, '')
for i in range(row_count):
if i < len(config_lines):
config_type, config_data = config_lines[i]
else:
config_type, config_data = "", ""
addr_set = addr_rows[i] if i < len(addr_rows) else ""
if i == 0:
zone_table.row(locked, name, config_type, config_data, addr_set, services_display)
else:
zone_table.row('', '', config_type, config_data, addr_set, '')
return zone_table
@@ -4645,6 +4660,9 @@ def show_firewall_zone(json, zone_name=None):
networks = zone.get('network', [])
if not networks:
networks = ""
address_sets = zone.get('address-set', [])
if not address_sets:
address_sets = ""
services = zone.get('service', [])
action = zone.get('action', 'reject')
@@ -4660,6 +4678,7 @@ def show_firewall_zone(json, zone_name=None):
print(f"{'action':<20}: {action}")
print(f"{'interface':<20}: {compress_interface_list(interfaces)}")
print(f"{'networks':<20}: {', '.join(networks)}")
print(f"{'address-sets':<20}: {', '.join(address_sets)}")
print(f"{'services (to HOST)':<20}: {services_display}")
# Show port forwards if any
@@ -4928,6 +4947,75 @@ def show_firewall_service(json, name=None):
service_table.print()
def firewall_address_set_table(json):
"""Create firewall address-sets table (returns SimpleTable or None)"""
fw = json.get('infix-firewall:firewall', {})
sets = fw.get('address-set', [])
if not sets:
return None
set_table = SimpleTable([
Column('NAME', flexible=True),
Column('FAMILY'),
Column('TIMEOUT'),
Column('STATIC'),
Column('DYNAMIC')
])
for aset in sets:
current = aset.get('current', [])
dynamic = sum(1 for cur in current if cur.get('dynamic'))
timeout = aset.get('timeout')
set_table.row(aset.get('name', ''),
aset.get('family', 'ipv4'),
f"{timeout} sec" if timeout else '',
str(len(current) - dynamic),
str(dynamic))
return set_table
def show_firewall_address_set(json, name=None):
"""Show firewall address-sets table or specific set details"""
fw = json.get('infix-firewall:firewall', {})
sets = fw.get('address-set', [])
if name:
aset = next((s for s in sets if s.get('name') == name), None)
if not aset:
print(f"Address-set '{name}' not found")
return
timeout = aset.get('timeout')
print(format_description('description', aset.get('description', '')))
print(f"{'name':<20}: {name}")
print(f"{'family':<20}: {aset.get('family', 'ipv4')}")
print(f"{'timeout':<20}: {f'{timeout} sec' if timeout else 'none'}")
print()
entry_table = SimpleTable([
Column('ENTRY'),
Column('TYPE'),
Column('EXPIRES')
])
for cur in aset.get('current', []):
expires = cur.get('expires')
entry_table.row(cur.get('entry', ''),
'dynamic' if cur.get('dynamic') else 'static',
f"{expires} sec" if expires is not None else '')
if entry_table.rows:
entry_table.min_width = 56
entry_table.print()
else:
print("(no entries)")
else:
set_table = firewall_address_set_table(json)
if set_table and set_table.rows:
set_table.min_width = 72
set_table.print()
else:
print("No address-sets configured")
def show_ospf(json_data):
"""Show OSPF general instance information"""
routing = json_data.get('ietf-routing:routing', {})
@@ -6018,6 +6106,8 @@ def main():
.add_argument('name', nargs='?', help='Policy name')
subparsers.add_parser('show-firewall-service', help='Show firewall services') \
.add_argument('name', nargs='?', help='Service name')
subparsers.add_parser('show-firewall-address-set', help='Show firewall address-sets') \
.add_argument('name', nargs='?', help='Address-set name')
subparsers.add_parser('show-firewall-log', help='Show firewall log') \
.add_argument('limit', nargs='?', help='Last N lines, default: all')
@@ -6095,6 +6185,8 @@ def main():
show_firewall_policy(json_data, args.name)
elif args.command == "show-firewall-service":
show_firewall_service(json_data, args.name)
elif args.command == "show-firewall-address-set":
show_firewall_address_set(json_data, args.name)
elif args.command == "show-firewall-log":
show_firewall_logs(args.limit)
elif args.command == "show-nacm":
+133 -2
View File
@@ -7,8 +7,37 @@ for the full API, see:
--object-path /org/fedoraproject/FirewallD1
"""
import dbus
import ipaddress
import re
from . import common
from .host import HOST
SHADOW_DIR = "/run/confd/address-sets"
def normalize_entry(entry):
"""Match firewalld entry normalization: bare address for host entries"""
try:
net = ipaddress.ip_network(str(entry), strict=False)
except ValueError:
return str(entry)
if net.prefixlen == net.max_prefixlen:
return str(net.network_address)
return str(net)
def split_sources(sources):
"""Zone sources are IP networks or 'ipset:NAME' address-set references"""
networks = []
ipsets = []
for src in sources:
src = str(src)
if src.startswith("ipset:"):
ipsets.append(src[len("ipset:"):])
else:
networks.append(src)
return networks, ipsets
def get_interface(interface="org.fedoraproject.FirewallD1"):
@@ -52,13 +81,15 @@ def get_zone_data(fw, name):
elif not short:
short = ""
networks, ipsets = split_sources(settings.get('sources', []))
zone = {
"name": name,
"short": short,
"immutable": immutable,
"description": settings.get('description', ''),
"interface": list(settings.get('interfaces', [])),
"network": list(settings.get('sources', [])),
"network": networks,
"address-set": ipsets,
"action": action.get(target, "accept"),
"service": list(settings.get('services', []))
}
@@ -132,8 +163,10 @@ def get_zones(fw):
for name, zone_info in active_zones.items():
zone_data = get_zone_data(fwz, name)
if zone_data:
networks, ipsets = split_sources(zone_info.get('sources', []))
zone_data['interface'] = list(zone_info.get('interfaces', []))
zone_data['network'] = list(zone_info.get('sources', []))
zone_data['network'] = networks
zone_data['address-set'] = ipsets
zones.append(zone_data)
except Exception as e:
@@ -281,6 +314,100 @@ def get_policies(fw):
return policies
def nft_set_elems(name):
"""Live contents of firewalld's nftables set
The kernel is the only source that sees entries in timeout sets,
and the only one tracking per-entry expiry. The firewalld table
is owner-protected, but reading is fine.
"""
data = HOST.run_json(("nft", "-j", "list", "set", "inet", "firewalld", name),
default={})
for obj in data.get("nftables", []):
if "set" in obj:
return obj["set"].get("elem", [])
return []
def nft_elem_parse(elem):
"""Return (entry, expires) from an nft JSON set element"""
expires = None
if isinstance(elem, dict) and "elem" in elem:
expires = elem["elem"].get("expires")
elem = elem["elem"].get("val")
if isinstance(elem, dict) and "prefix" in elem:
entry = f"{elem['prefix']['addr']}/{elem['prefix']['len']}"
elif isinstance(elem, dict) and "range" in elem:
entry = f"{elem['range'][0]}-{elem['range'][1]}"
else:
entry = str(elem)
return normalize_entry(entry), expires
def get_address_set(fwi, name):
try:
settings = fwi.getIPSetSettings(name)
# (version, short, description, type, options, entries)
options = settings[4]
tracked = [normalize_entry(e) for e in settings[5]]
except Exception as e:
common.LOG.warning("Failed querying ipset %s via D-Bus: %s", name, e)
return None
aset = {"name": str(name)}
description = str(settings[2])
if description:
aset["description"] = description
aset["family"] = "ipv6" if options.get("family") == "inet6" else "ipv4"
timeout = int(options.get("timeout", 0))
if timeout:
aset["timeout"] = timeout
lines = HOST.read_multiline(f"{SHADOW_DIR}/{name}", default=[])
shadow = {normalize_entry(line) for line in lines if line}
static = [e for e in tracked if e not in shadow]
if static:
aset["entry"] = static
current = []
for elem in nft_set_elems(name):
entry, expires = nft_elem_parse(elem)
cur = {"entry": entry, "dynamic": bool(timeout) or entry in shadow}
if expires is not None:
cur["expires"] = int(expires)
current.append(cur)
if current:
aset["current"] = current
return aset
def get_address_sets():
sets = []
fwi = get_interface("org.fedoraproject.FirewallD1.ipset")
if not fwi:
return sets
try:
names = fwi.getIPSets()
except Exception as e:
common.LOG.warning("Failed querying ipsets: %s", e)
return sets
for name in names:
data = get_address_set(fwi, name)
if data:
sets.append(data)
return sets
def get_service_data(fw, name):
try:
settings = fw.getServiceSettings2(name)
@@ -360,4 +487,8 @@ def operational():
if services:
data["infix-firewall:firewall"]["service"] = services
address_sets = get_address_sets()
if address_sets:
data["infix-firewall:firewall"]["address-set"] = address_sets
return data
+145 -25
View File
@@ -38,8 +38,10 @@ type cfgZoneRow struct {
IfaceCount int
IfaceSet map[string]bool
ServiceSet map[string]bool
AddrSetSet map[string]bool
ServicesTxt string // fallback when ServiceOptions unavailable
NetworksTxt string // comma-separated, shown read-only when zone uses networks
AddrSetsTxt string // comma-separated address-set sources
}
type cfgPolicyRow struct {
@@ -56,6 +58,20 @@ type cfgServiceRow struct {
PortsDisplay string // "tcp:80,443; udp:53" — at-a-glance
}
type cfgAddrSetRow struct {
addressSetJSON
EntriesTxt string // one entry per line for the textarea
}
// toSet builds a membership map for template "index" lookups.
func toSet(ss []string) map[string]bool {
set := make(map[string]bool, len(ss))
for _, s := range ss {
set[s] = true
}
return set
}
// cfgFwSvcWrapper is used when reading a single service by path.
type cfgFwSvcWrapper struct {
Service []fwServiceJSON `json:"infix-firewall:service"`
@@ -74,12 +90,15 @@ type cfgFirewallPageData struct {
ZoneNames []string // for policy ingress/egress multi-select
Policies []cfgPolicyRow
Services []cfgServiceRow
AddressSets []cfgAddrSetRow
AddressSetNames []string // for zone source multi-select
ProtoOptions []schema.IdentityOption
Desc map[string]string
LoggingOptions []schema.IdentityOption
ActionOptions []schema.IdentityOption
PolicyActionOptions []schema.IdentityOption
ServiceOptions []schema.IdentityOption
FamilyOptions []schema.IdentityOption
AllInterfaces []string
Error string
}
@@ -107,6 +126,7 @@ func (h *ConfigureFirewallHandler) Overview(w http.ResponseWriter, r *http.Reque
zPath := fwPath + "/zone"
pPath := fwPath + "/policy"
sPath := fwPath + "/service"
aPath := fwPath + "/address-set"
data.Desc = map[string]string{
"enabled": schema.DescriptionOf(mgr, fwPath+"/enabled"),
"default": schema.DescriptionOf(mgr, fwPath+"/default"),
@@ -127,12 +147,19 @@ func (h *ConfigureFirewallHandler) Overview(w http.ResponseWriter, r *http.Reque
"service-port-lower": schema.DescriptionOf(mgr, sPath+"/port/lower"),
"service-port-upper": schema.DescriptionOf(mgr, sPath+"/port/upper"),
"service-port-proto": schema.DescriptionOf(mgr, sPath+"/port/proto"),
"zone-address-set": schema.DescriptionOf(mgr, zPath+"/address-set"),
"addrset-name": schema.DescriptionOf(mgr, aPath+"/name"),
"addrset-description": schema.DescriptionOf(mgr, aPath+"/description"),
"addrset-family": schema.DescriptionOf(mgr, aPath+"/family"),
"addrset-timeout": schema.DescriptionOf(mgr, aPath+"/timeout"),
"addrset-entry": schema.DescriptionOf(mgr, aPath+"/entry"),
}
data.LoggingOptions = schema.OptionsFor(mgr, fwPath+"/logging")
data.ActionOptions = schema.OptionsFor(mgr, zPath+"/action")
data.PolicyActionOptions = schema.OptionsFor(mgr, pPath+"/action")
data.ServiceOptions = schema.OptionsFor(mgr, zPath+"/service")
data.ProtoOptions = schema.OptionsFor(mgr, sPath+"/port/proto")
data.FamilyOptions = schema.OptionsFor(mgr, aPath+"/family")
}
fw, active, err := h.fetchFirewall(r.Context())
@@ -150,44 +177,45 @@ func (h *ConfigureFirewallHandler) Overview(w http.ResponseWriter, r *http.Reque
data.Default = fw.Default
data.Logging = fw.Logging
for _, z := range fw.Zone {
ifaceSet := make(map[string]bool, len(z.Interface))
for _, iface := range z.Interface {
ifaceSet[iface] = true
}
svcSet := make(map[string]bool, len(z.Service))
for _, svc := range z.Service {
svcSet[svc] = true
}
data.Zones = append(data.Zones, cfgZoneRow{
zoneJSON: z,
IfaceCount: len(z.Interface),
IfaceSet: ifaceSet,
ServiceSet: svcSet,
IfaceSet: toSet(z.Interface),
ServiceSet: toSet(z.Service),
AddrSetSet: toSet(z.AddressSet),
ServicesTxt: strings.Join(z.Service, "\n"),
NetworksTxt: strings.Join(z.Network, ", "),
AddrSetsTxt: strings.Join(z.AddressSet, ", "),
})
data.ZoneNames = append(data.ZoneNames, z.Name)
}
for _, s := range fw.AddressSet {
if s.Family == "" {
// Display the schema default when the leaf is unset.
for _, opt := range data.FamilyOptions {
if opt.IsDefault {
s.Family = opt.Value
}
}
}
data.AddressSets = append(data.AddressSets, cfgAddrSetRow{
addressSetJSON: s,
EntriesTxt: strings.Join(s.Entry, "\n"),
})
data.AddressSetNames = append(data.AddressSetNames, s.Name)
}
for _, p := range fw.Policy {
masq := "—"
if p.Masquerade {
masq = "Yes"
}
ingressSet := make(map[string]bool, len(p.Ingress))
for _, z := range p.Ingress {
ingressSet[z] = true
}
egressSet := make(map[string]bool, len(p.Egress))
for _, z := range p.Egress {
egressSet[z] = true
}
data.Policies = append(data.Policies, cfgPolicyRow{
policyJSON: p,
IngressDisplay: strings.Join(p.Ingress, ", "),
EgressDisplay: strings.Join(p.Egress, ", "),
MasqDisplay: masq,
IngressSet: ingressSet,
EgressSet: egressSet,
IngressSet: toSet(p.Ingress),
EgressSet: toSet(p.Egress),
})
}
for _, s := range fw.Service {
@@ -303,7 +331,7 @@ func (h *ConfigureFirewallHandler) DeleteZone(w http.ResponseWriter, r *http.Req
renderSavedRedirect(w, "Zone deleted", "/configure/firewall")
}
// SaveZone updates a zone's action, description, interfaces, and services.
// SaveZone updates a zone's action, description, interfaces, address-sets, and services.
// Uses read-modify-write to preserve fields not managed by this UI (network,
// port-forward). Note: port-forward entries are lost on save (Phase 3 limitation).
// POST /configure/firewall/zones/{name}
@@ -340,12 +368,18 @@ func (h *ConfigureFirewallHandler) SaveZone(w http.ResponseWriter, r *http.Reque
svcs = []string{}
}
cur.Service = svcs
sets := r.Form["address-sets"]
if sets == nil {
sets = []string{}
}
cur.AddressSet = sets
zone := map[string]any{
"name": cur.Name,
"action": cur.Action,
"interface": cur.Interface,
"service": cur.Service,
"name": cur.Name,
"action": cur.Action,
"interface": cur.Interface,
"service": cur.Service,
"address-set": cur.AddressSet,
}
if cur.Description != "" {
zone["description"] = cur.Description
@@ -398,6 +432,9 @@ func (h *ConfigureFirewallHandler) resetZoneLeafList(w http.ResponseWriter, r *h
if len(cur.Network) > 0 {
zone["network"] = cur.Network
}
if len(cur.AddressSet) > 0 {
zone["address-set"] = cur.AddressSet
}
if len(cur.Service) > 0 {
zone["service"] = cur.Service
}
@@ -692,6 +729,89 @@ func (h *ConfigureFirewallHandler) DeleteService(w http.ResponseWriter, r *http.
renderSavedRedirect(w, "Service deleted", "/configure/firewall")
}
// ─── Address-sets CRUD ───────────────────────────────────────────────────────
// parseAddressSet builds the RESTCONF address-set body from the add/save
// form. Entries come from a textarea, one address or prefix per line.
func parseAddressSet(r *http.Request, name string) (map[string]any, error) {
set := map[string]any{"name": name}
if desc := strings.TrimSpace(r.FormValue("description")); desc != "" {
set["description"] = desc
}
if fam := r.FormValue("family"); fam != "" {
set["family"] = fam
}
if t := strings.TrimSpace(r.FormValue("timeout")); t != "" {
tv, err := strconv.Atoi(t)
if err != nil || tv < 1 {
return nil, fmt.Errorf("timeout must be a positive number of seconds")
}
set["timeout"] = tv
}
entries := []string{}
for _, line := range strings.Split(r.FormValue("entries"), "\n") {
if line = strings.TrimSpace(line); line != "" {
entries = append(entries, line)
}
}
if len(entries) > 0 {
set["entry"] = entries
}
return set, nil
}
func (h *ConfigureFirewallHandler) putAddressSet(w http.ResponseWriter, r *http.Request, name, saved string) {
set, err := parseAddressSet(r, name)
if err != nil {
renderSaveError(w, err)
return
}
body := map[string]any{"infix-firewall:address-set": []map[string]any{set}}
if err := h.RC.Put(r.Context(), fwConfigPath+"/address-set="+restconf.EscapeKey(name), body); err != nil {
log.Printf("configure firewall address-set save %q: %v", name, err)
renderSaveError(w, err)
return
}
renderSavedRedirect(w, saved, "/configure/firewall")
}
// AddAddressSet creates a new address-set.
// POST /configure/firewall/address-sets
func (h *ConfigureFirewallHandler) AddAddressSet(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
renderSaveError(w, fmt.Errorf("address-set name is required"))
return
}
h.putAddressSet(w, r, name, "Address-set added")
}
// SaveAddressSet updates an existing address-set.
// POST /configure/firewall/address-sets/{name}
func (h *ConfigureFirewallHandler) SaveAddressSet(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
h.putAddressSet(w, r, r.PathValue("name"), "Address-set saved")
}
// DeleteAddressSet removes an address-set.
// DELETE /configure/firewall/address-sets/{name}
func (h *ConfigureFirewallHandler) DeleteAddressSet(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if err := h.RC.Delete(r.Context(), fwConfigPath+"/address-set="+restconf.EscapeKey(name)); err != nil {
log.Printf("configure firewall address-set delete %q: %v", name, err)
renderSaveError(w, err)
return
}
renderSavedRedirect(w, "Address-set deleted", "/configure/firewall")
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
// fetchInterfaceNames returns configured interface names from candidate (fallback running).
@@ -0,0 +1,163 @@
// SPDX-License-Identifier: MIT
package handlers
import (
"context"
"encoding/json"
"html/template"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"strings"
"testing"
"infix/webui/internal/restconf"
"infix/webui/internal/schema"
"infix/webui/internal/security"
"infix/webui/internal/testutil"
)
var minimalCfgFwTmpl = template.Must(template.New("configure-firewall.html").Parse(
`{{define "configure-firewall.html"}}{{template "content" .}}{{end}}` +
`{{define "content"}}sets={{len .AddressSets}}` +
`{{range .AddressSets}};{{.Name}}:{{.EntriesTxt}}:{{if .Timeout}}{{.Timeout}}{{end}}{{end}}` +
`{{range .Zones}};zone-{{.Name}}:{{.AddrSetsTxt}}{{end}}{{end}}`,
))
func TestConfigureFirewallOverview_AddressSets(t *testing.T) {
mock := testutil.NewMockFetcher()
mock.SetResponse(candidatePath+"/infix-firewall:firewall", map[string]any{
"infix-firewall:firewall": map[string]any{
"default": "trusted",
"zone": []map[string]any{{
"name": "trusted",
"action": "accept",
"address-set": []string{"allowed"},
}},
"address-set": []map[string]any{{
"name": "allowed",
"entry": []string{"192.168.1.40", "10.0.0.0/24"},
}, {
"name": "banned",
"timeout": 3600,
}},
},
})
h := &ConfigureFirewallHandler{
Template: minimalCfgFwTmpl,
RC: mock,
Schema: schema.NewCache(mock, t.TempDir()),
}
req := httptest.NewRequest(http.MethodGet, "/configure/firewall", nil)
ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{
Username: "admin",
Password: "admin",
})
ctx = security.WithToken(ctx, "test-csrf-token")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.Overview(w, req)
if w.Code != http.StatusOK {
t.Fatalf("want 200 got %d; body: %s", w.Code, w.Body.String())
}
body := w.Body.String()
for _, want := range []string{
"sets=2",
";allowed:192.168.1.40\n10.0.0.0/24:",
";banned::3600",
";zone-trusted:allowed",
} {
if !strings.Contains(body, want) {
t.Errorf("body missing %q; body: %s", want, body)
}
}
}
type recordingFetcher struct {
*testutil.MockFetcher
putCalls int
lastPath string
lastBody any
}
func (r *recordingFetcher) Put(_ context.Context, path string, body any) error {
r.putCalls++
r.lastPath = path
r.lastBody = body
return nil
}
func TestConfigureFirewallSaveZoneAllowsInterfacesWithAddressSets(t *testing.T) {
mock := &recordingFetcher{MockFetcher: testutil.NewMockFetcher()}
mock.SetResponse(candidatePath+"/infix-firewall:firewall/zone=public", map[string]any{
"infix-firewall:zone": []map[string]any{{
"name": "public",
"action": "drop",
"interface": []string{"eth0"},
}},
})
h := &ConfigureFirewallHandler{
Template: minimalCfgFwTmpl,
RC: mock,
Schema: schema.NewCache(mock, t.TempDir()),
}
form := url.Values{
"action": {"drop"},
"description": {"Public zone"},
"interfaces": {"eth0"},
"address-sets": {"allowed"},
}
req := httptest.NewRequest(http.MethodPost, "/configure/firewall/zones/public", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetPathValue("name", "public")
ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{
Username: "admin",
Password: "admin",
})
ctx = security.WithToken(ctx, "test-csrf-token")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.SaveZone(w, req)
if mock.putCalls != 1 {
t.Fatalf("want 1 PUT call got %d", mock.putCalls)
}
if w.Code != http.StatusNoContent {
t.Fatalf("want 204 got %d; body: %s", w.Code, w.Body.String())
}
if got, want := mock.lastPath, candidatePath+"/infix-firewall:firewall/zone=public"; got != want {
t.Fatalf("want PUT path %q got %q", want, got)
}
body, ok := mock.lastBody.(map[string]any)
if !ok {
t.Fatalf("unexpected PUT body type %T", mock.lastBody)
}
zones, ok := body["infix-firewall:zone"].([]map[string]any)
if !ok || len(zones) != 1 {
t.Fatalf("unexpected PUT zone payload %#v", body["infix-firewall:zone"])
}
zone := zones[0]
if got, want := zone["interface"], []string{"eth0"}; !reflect.DeepEqual(got, want) {
t.Fatalf("want interfaces %#v got %#v", want, got)
}
if got, want := zone["address-set"], []string{"allowed"}; !reflect.DeepEqual(got, want) {
t.Fatalf("want address-sets %#v got %#v", want, got)
}
var trig map[string]string
if err := json.Unmarshal([]byte(w.Header().Get("HX-Trigger")), &trig); err != nil {
t.Fatalf("unmarshal HX-Trigger: %v", err)
}
if got := trig["cfgSaved"]; !strings.Contains(got, "Zone saved") {
t.Fatalf("unexpected success message %q", got)
}
}
+77 -17
View File
@@ -19,13 +19,32 @@ type firewallWrapper struct {
}
type firewallJSON struct {
Enabled *yangBool `json:"enabled"` // YANG default: true; nil means enabled
Default string `json:"default"`
Logging string `json:"logging"`
Lockdown yangBool `json:"lockdown"`
Zone []zoneJSON `json:"zone"`
Policy []policyJSON `json:"policy"`
Service []fwServiceJSON `json:"service"`
Enabled *yangBool `json:"enabled"` // YANG default: true; nil means enabled
Default string `json:"default"`
Logging string `json:"logging"`
Lockdown yangBool `json:"lockdown"`
Zone []zoneJSON `json:"zone"`
Policy []policyJSON `json:"policy"`
Service []fwServiceJSON `json:"service"`
AddressSet []addressSetJSON `json:"address-set"`
}
// addressSetJSON models a named set of IP addresses/networks usable as zone
// source. The current list is operational state: the live set contents,
// including dynamic entries added at runtime.
type addressSetJSON struct {
Name string `json:"name"`
Description string `json:"description"`
Family string `json:"family"`
Timeout yangInt64 `json:"timeout"`
Entry []string `json:"entry"`
Current []addrSetCurJSON `json:"current"`
}
type addrSetCurJSON struct {
Entry string `json:"entry"`
Dynamic bool `json:"dynamic"`
Expires *yangInt64 `json:"expires"`
}
// fwServiceJSON models a user-defined firewall service (port + protocol bundle).
@@ -50,6 +69,7 @@ type zoneJSON struct {
Description string `json:"description"`
Interface []string `json:"interface"`
Network []string `json:"network"`
AddressSet []string `json:"address-set"`
Service []string `json:"service"`
PortForward []portForwardJSON `json:"port-forward"`
Immutable bool `json:"immutable"`
@@ -92,6 +112,7 @@ type firewallData struct {
Matrix []matrixRow
Zones []zoneEntry
Policies []policyEntry
AddressSets []addressSetEntry
Error string
}
@@ -110,11 +131,25 @@ type matrixCell struct {
}
type zoneEntry struct {
Name string
Action string
Interfaces string
Networks string
Services string // services allowed to HOST from this zone
Name string
Action string
Interfaces string
Networks string
AddressSets string
Services string // services allowed to HOST from this zone
}
type addressSetEntry struct {
Name string
Family string
Timeout string // "3600 s" for expiring sets, "" otherwise
Entries []addressSetEntryRow
}
type addressSetEntryRow struct {
Entry string
Dynamic bool
Expires string // remaining lifetime, "" when not expiring
}
type policyEntry struct {
@@ -169,14 +204,39 @@ func (h *FirewallHandler) Overview(w http.ResponseWriter, r *http.Request) {
for _, z := range f.Zone {
data.Zones = append(data.Zones, zoneEntry{
Name: z.Name,
Action: z.Action,
Interfaces: strings.Join(z.Interface, ", "),
Networks: strings.Join(z.Network, ", "),
Services: strings.Join(z.Service, ", "),
Name: z.Name,
Action: z.Action,
Interfaces: strings.Join(z.Interface, ", "),
Networks: strings.Join(z.Network, ", "),
AddressSets: strings.Join(z.AddressSet, ", "),
Services: strings.Join(z.Service, ", "),
})
}
for _, s := range f.AddressSet {
set := addressSetEntry{
Name: s.Name,
Family: s.Family,
}
if set.Family == "" {
set.Family = "ipv4" // YANG default
}
if s.Timeout > 0 {
set.Timeout = formatDuration(int64(s.Timeout))
}
for _, cur := range s.Current {
row := addressSetEntryRow{
Entry: cur.Entry,
Dynamic: cur.Dynamic,
}
if cur.Expires != nil {
row.Expires = formatDuration(int64(*cur.Expires))
}
set.Entries = append(set.Entries, row)
}
data.AddressSets = append(data.AddressSets, set)
}
for _, p := range f.Policy {
data.Policies = append(data.Policies, policyEntry{
Name: p.Name,
+3
View File
@@ -424,6 +424,9 @@ func New(
mux.HandleFunc("POST /configure/firewall/services", cfgFw.AddService)
mux.HandleFunc("POST /configure/firewall/services/{name}", cfgFw.SaveService)
mux.HandleFunc("DELETE /configure/firewall/services/{name}", cfgFw.DeleteService)
mux.HandleFunc("POST /configure/firewall/address-sets", cfgFw.AddAddressSet)
mux.HandleFunc("POST /configure/firewall/address-sets/{name}", cfgFw.SaveAddressSet)
mux.HandleFunc("DELETE /configure/firewall/address-sets/{name}", cfgFw.DeleteAddressSet)
mux.HandleFunc("GET /configure/hardware", cfgHw.Overview)
mux.HandleFunc("POST /configure/hardware", cfgHw.CreateHardware)
mux.HandleFunc("POST /configure/hardware/usb/{name}", cfgHw.SaveUSBPort)
@@ -112,6 +112,7 @@
<th>Action{{template "field-info" (index $d "zone-action")}}</th>
<th>Description{{template "field-info" (index $d "zone-description")}}</th>
<th style="text-align:center">Interfaces</th>
<th>Address sets{{template "field-info" (index $d "zone-address-set")}}</th>
<th></th>
</tr>
</thead>
@@ -128,6 +129,7 @@
<td>{{if .Action}}{{.Action}}{{else}}reject{{end}}</td>
<td>{{if .Description}}{{.Description}}{{else}}<span class="text-muted"></span>{{end}}</td>
<td style="text-align:center">{{.IfaceCount}}</td>
<td>{{if .AddressSet}}{{.AddrSetsTxt}}{{else}}<span class="text-muted"></span>{{end}}</td>
<td style="text-align:right">
{{if not .Immutable}}
<button type="button" class="btn-icon btn-icon-danger"
@@ -141,12 +143,13 @@
</td>
</tr>
<tr class="key-detail-row" id="key-detail-zone-{{$i}}">
<td colspan="5" class="key-detail-cell">
<td colspan="6" class="key-detail-cell">
<div class="key-detail-body">
{{if .Immutable}}
<p class="empty-message">This zone is system-defined and cannot be modified.</p>
{{if .Interface}}<p class="text-muted" style="margin-top:0.5rem">Interfaces: {{range $j, $iface := .Interface}}{{if $j}}, {{end}}{{$iface}}{{end}}</p>{{end}}
{{if .Network}}<p class="text-muted" style="margin-top:0.25rem">Networks: {{.NetworksTxt}}</p>{{end}}
{{if .AddressSet}}<p class="text-muted" style="margin-top:0.25rem">Address sets: {{.AddrSetsTxt}}</p>{{end}}
{{if .Service}}<p class="text-muted" style="margin-top:0.25rem">Services: {{.ServicesTxt}}</p>{{end}}
{{else}}
<form hx-post="/configure/firewall/zones/{{.Name}}" hx-swap="none">
@@ -214,6 +217,24 @@
</td>
</tr>
{{end}}
{{if $.AddressSetNames}}
<tr>
<th>Address sets{{template "field-info" (index $d "zone-address-set")}}</th>
<td colspan="2">
<details class="cfg-multi">
<summary class="cfg-multi-summary">{{if .AddressSet}}{{.AddrSetsTxt}}{{else}}(None){{end}}</summary>
<div class="cfg-multi-body">
{{range $.AddressSetNames}}
<label class="cfg-multi-item">
<input type="checkbox" name="address-sets" value="{{.}}" {{if index $z.AddrSetSet .}}checked{{end}}>
{{.}}
</label>
{{end}}
</div>
</details>
</td>
</tr>
{{end}}
<tr>
<th>Services{{template "field-info" (index $d "zone-service")}}</th>
<td>
@@ -723,6 +744,160 @@
</div>
</section>
{{/* ── Address Sets ─────────────────────────────────────────────────── */}}
<section class="info-card info-grid-span-2">
<div class="card-header">Address Sets</div>
<p class="text-muted" style="font-size:0.85em;margin:0 1rem 0.5rem">
Named sets of IP addresses and networks, usable as zone sources.
Entries configured here are static; dynamic entries can be added at
runtime from the CLI or over NETCONF/RESTCONF, see the Firewall
status page for the live contents.
</p>
<div class="data-table-wrap">
<table class="data-table cfg-table">
<thead>
<tr>
<th>Name{{template "field-info" (index $d "addrset-name")}}</th>
<th>Description{{template "field-info" (index $d "addrset-description")}}</th>
<th>Family{{template "field-info" (index $d "addrset-family")}}</th>
<th>Timeout{{template "field-info" (index $d "addrset-timeout")}}</th>
<th style="text-align:center">Entries</th>
<th></th>
</tr>
</thead>
<tbody>
{{range $i, $s := .AddressSets}}
<tr>
<td>
<button class="key-row-toggle" type="button"
aria-expanded="false" data-target="addrset-{{$i}}">
<span class="key-row-arrow" aria-hidden="true"></span>{{.Name}}
</button>
</td>
<td>{{if .Description}}{{.Description}}{{else}}<span class="text-muted"></span>{{end}}</td>
<td>{{.Family}}</td>
<td>{{if .Timeout}}{{.Timeout}} s{{else}}<span class="text-muted"></span>{{end}}</td>
<td style="text-align:center">{{len .Entry}}</td>
<td style="text-align:right">
<button type="button" class="btn-icon btn-icon-danger"
hx-delete="/configure/firewall/address-sets/{{.Name}}"
hx-confirm="Delete address-set {{.Name}}?"
hx-swap="none"
title="Delete address-set">
{{template "icon-trash"}}
</button>
</td>
</tr>
<tr class="key-detail-row" id="key-detail-addrset-{{$i}}">
<td colspan="6" class="key-detail-cell">
<div class="key-detail-body">
<form hx-post="/configure/firewall/address-sets/{{.Name}}" hx-swap="none">
<table class="info-table">
<tr>
<th>Description{{template "field-info" (index $d "addrset-description")}}</th>
<td colspan="2"><input class="cfg-input" type="text" name="description"
value="{{.Description}}" placeholder="Optional description"></td>
</tr>
<tr>
<th>Family{{template "field-info" (index $d "addrset-family")}}</th>
<td colspan="2">
{{$fam := .Family}}
<div class="yt-bool-group">
{{range $.FamilyOptions}}
<label><input type="radio" name="family" value="{{.Value}}"
{{if or (eq $fam .Value) (and (eq $fam "") .IsDefault)}}checked{{end}}> {{.Label}}</label>
{{end}}
</div>
</td>
</tr>
<tr>
<th>Timeout{{template "field-info" (index $d "addrset-timeout")}}</th>
<td colspan="2">
<input class="cfg-input cfg-input-sm" type="number" name="timeout"
min="1" value="{{if .Timeout}}{{.Timeout}}{{end}}" placeholder="—" style="width:8rem"> seconds
<span class="text-muted" style="font-size:0.8em;display:block;margin-top:0.25em">Timeout sets are dynamic-only: entries expire on their own and static entries cannot be configured.</span>
</td>
</tr>
<tr>
<th>Entries{{template "field-info" (index $d "addrset-entry")}}</th>
<td colspan="2">
<textarea class="cfg-input" name="entries" rows="5"
placeholder="One address or prefix per line, e.g. 192.168.1.40 or 10.0.0.0/24">{{.EntriesTxt}}</textarea>
</td>
</tr>
</table>
<div class="cfg-card-footer" style="padding-left:0">
<button class="btn btn-primary btn-sm" type="submit">Save</button>
<button type="button" class="btn btn-sm" data-close-detail="addrset-{{$i}}">Cancel</button>
<span class="cfg-save-status"></span>
</div>
</form>
</div>
</td>
</tr>
{{end}}
{{if not .AddressSets}}
<tr><td colspan="6" class="yt-table-empty">No address-sets configured</td></tr>
{{end}}
{{/* Hidden add-address-set row */}}
<tr id="add-addrset-row" hidden>
<td colspan="6" class="key-detail-cell">
<form hx-post="/configure/firewall/address-sets" hx-swap="none">
<table class="info-table">
<tr>
<th>Name{{template "field-info" (index $d "addrset-name")}}</th>
<td colspan="2"><input class="cfg-input" type="text" name="name" required
pattern="[a-zA-Z0-9\-_]+" placeholder="e.g. allowed"></td>
</tr>
<tr>
<th>Description{{template "field-info" (index $d "addrset-description")}}</th>
<td colspan="2"><input class="cfg-input" type="text" name="description"
placeholder="Optional description"></td>
</tr>
<tr>
<th>Family{{template "field-info" (index $d "addrset-family")}}</th>
<td colspan="2">
<div class="yt-bool-group">
{{range .FamilyOptions}}
<label><input type="radio" name="family" value="{{.Value}}" {{if .IsDefault}}checked{{end}}> {{.Label}}</label>
{{end}}
</div>
</td>
</tr>
<tr>
<th>Timeout{{template "field-info" (index $d "addrset-timeout")}}</th>
<td colspan="2">
<input class="cfg-input cfg-input-sm" type="number" name="timeout"
min="1" placeholder="—" style="width:8rem"> seconds
<span class="text-muted" style="font-size:0.8em;display:block;margin-top:0.25em">Leave blank for a regular set with static entries.</span>
</td>
</tr>
<tr>
<th>Entries{{template "field-info" (index $d "addrset-entry")}}</th>
<td colspan="2">
<textarea class="cfg-input" name="entries" rows="5"
placeholder="One address or prefix per line, e.g. 192.168.1.40 or 10.0.0.0/24"></textarea>
</td>
</tr>
</table>
<div class="cfg-card-footer" style="padding-left:0">
<button class="btn btn-primary btn-sm" type="submit">Add</button>
<button type="button" class="btn btn-sm" data-hide="add-addrset-row">Cancel</button>
<span class="cfg-save-status"></span>
</div>
</form>
</td>
</tr>
</tbody>
</table>
</div>
<div class="cfg-card-footer">
<button type="button" class="btn-add-row" data-show="add-addrset-row">+ Add Address Set</button>
</div>
</section>
</section>
{{end}}
@@ -731,4 +906,3 @@
{{end}}{{/* end {{else}} — schema ready */}}
{{end}}
+36
View File
@@ -90,6 +90,7 @@
<th>Action</th>
<th>Interfaces</th>
<th>Networks</th>
<th>Address Sets</th>
<th>Host Services</th>
</tr>
</thead>
@@ -100,6 +101,7 @@
<td><span class="badge badge-{{.Action}}">{{.Action}}</span></td>
<td>{{.Interfaces}}</td>
<td>{{if .Networks}}{{.Networks}}{{else}}<span class="text-muted"></span>{{end}}</td>
<td>{{if .AddressSets}}{{.AddressSets}}{{else}}<span class="text-muted"></span>{{end}}</td>
<td>{{if .Services}}{{.Services}}{{else}}<span class="text-muted"></span>{{end}}</td>
</tr>
{{end}}
@@ -109,6 +111,40 @@
</div>
{{end}}
{{if .AddressSets}}
<div class="info-card info-grid-span-2">
<div class="card-header">Address Sets</div>
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Family</th>
<th>Timeout</th>
<th>Entries</th>
</tr>
</thead>
<tbody>
{{range .AddressSets}}
<tr>
<td>{{.Name}}</td>
<td>{{.Family}}</td>
<td>{{if .Timeout}}{{.Timeout}}{{else}}<span class="text-muted"></span>{{end}}</td>
<td>
{{if .Entries}}
{{range $i, $e := .Entries}}{{if $i}}, {{end}}<code>{{.Entry}}</code>{{if .Dynamic}} <span class="badge badge-neutral" title="Added at runtime, not saved to configuration{{if .Expires}}, expires in {{.Expires}}{{end}}">dyn{{if .Expires}} {{.Expires}}{{end}}</span>{{end}}{{end}}
{{else}}
<span class="text-muted">(empty)</span>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}
{{if .Policies}}
<div class="info-card info-grid-span-2">
<div class="card-header">Policies</div>