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
+8
View File
@@ -3,6 +3,14 @@ Change Log
All notable changes to the project are documented in this file.
[UNRELEASED][]
--------------
### Changes
- Add support for firewall address-set (ipset): named sets of IP addresses and
networks, usable as zone sources for per-IP access control, issue #1189
[v26.06.0][] - 2026-07-01
-------------------------
+90
View File
@@ -223,6 +223,96 @@ The firewall includes over 100 pre-defined services, such as:
> See the YANG model for the full list, or tap the ++question++ key
> when setting up an allowed host service in a zone `set service`
## Address Sets
Address sets are named collections of IP addresses and networks that can be
used as zone *sources*, alongside the `network` setting. Traffic from a
member of the set is classified into that zone regardless of which interface
it arrives on. Since source matching takes precedence over interface
matching, an address set in a trusted zone can selectively lift devices out
of a restrictive interface zone.
This enables per-IP access control: block everything by default and grant
individual end devices access at runtime.
> [!IMPORTANT]
> Assigning an address set to a zone only decides which zone the source IP
> belongs to. It does **not** by itself grant access to the device. Access
> to HOST services is still controlled by the zone's `action` and `service`
> settings. A common pattern is to keep the interface or default zone
> restrictive (`reject`/`drop`) and attach the address set to a separate
> trusted zone with `action accept`, as shown below.
<pre class="cli"><code>admin@example:/> <b>configure</b>
admin@example:/config/> <b>edit firewall address-set allowed</b>
admin@example:/config/firewall/…/allowed/> <b>set description "End devices granted access"</b>
admin@example:/config/firewall/…/allowed/> <b>set entry 192.168.1.40</b>
admin@example:/config/firewall/…/allowed/> <b>end</b>
admin@example:/config/firewall/> <b>edit zone trusted</b>
admin@example:/config/firewall/…/trusted/> <b>set action accept</b>
admin@example:/config/firewall/…/trusted/> <b>set address-set allowed</b>
admin@example:/config/firewall/…/trusted/> <b>leave</b>
</code></pre>
### Static and Dynamic Entries
Entries come in two kinds:
- **Static** entries are set in the configuration, like `192.168.1.40`
above, and are restored at boot
- **Dynamic** entries are added and removed at runtime using the `add`,
`remove`, and `flush` actions. They take effect immediately and survive
firewall configuration changes, but are *not* saved to the configuration,
so a reboot starts from a clean slate
From admin-exec context in the CLI:
<pre class="cli"><code>admin@example:/> <b>firewall address-set allowed add 192.168.1.42</b>
admin@example:/> <b>show firewall address-set allowed</b>
name : allowed
family : ipv4
timeout : none
ENTRY TYPE EXPIRES
192.168.1.40 static
192.168.1.42 dynamic
admin@example:/> <b>firewall address-set allowed remove 192.168.1.42</b>
</code></pre>
The same actions are available over NETCONF and RESTCONF, e.g., allowing a
device from a network management system:
```json
~$ curl -kX POST -u admin:admin -H "Content-Type: application/yang-data+json" \
-d '{"infix-firewall:input": {"entry": "192.168.1.42"}}' \
https://example.local/restconf/data/infix-firewall:firewall/address-set=allowed/add
```
Static entries can only be removed by changing the configuration, the
`remove` action manages dynamic entries only. The `flush` action removes
all dynamic entries at once, leaving static entries in place.
### Expiring Entries
An address set can be created with a `timeout`, giving every dynamic entry a
limited lifetime. Such sets are dynamic-only: static entries cannot be
configured, and entries cannot be removed manually, they expire on their
own. This suits time-limited access grants and automated ban lists.
<pre class="cli"><code>admin@example:/config/firewall/> <b>edit address-set banned</b>
admin@example:/config/firewall/…/banned/> <b>set timeout 3600</b>
admin@example:/config/firewall/…/banned/> <b>leave</b>
admin@example:/> <b>firewall address-set banned add 203.0.113.99</b>
</code></pre>
The remaining lifetime of each entry is shown in the `EXPIRES` column of
<kbd>show firewall address-set</kbd>.
> [!NOTE]
> Entries in timeout sets do not survive firewall configuration changes,
> the set is flushed when the firewall configuration is rebuilt. Regular
> (non-timeout) sets keep their dynamic entries over configuration changes.
## Examples
### End Device Protection
+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>
+3
View File
@@ -37,3 +37,6 @@
- "json/bloated.json"
- "show-interfaces"
- "-n br0"
- case: firewall_overview.sh
name: "firewall-overview"
+34
View File
@@ -0,0 +1,34 @@
#!/bin/sh
SCRIPT_PATH="$(dirname "$(readlink -f "$0")")"
JSON="$SCRIPT_PATH/json/firewall-overview.json"
CLI="$SCRIPT_PATH/../../../src/statd/python/cli_pretty/cli_pretty.py"
strip_ansi() {
sed 's/\x1b\[[0-9;]*m//g'
}
echo "1..2"
OUT1="$(cat "$JSON" | "$CLI" show-firewall | strip_ansi)"
if printf '%s\n' "$OUT1" | grep -q "Address Sets" &&
printf '%s\n' "$OUT1" | grep -q "allowed" &&
printf '%s\n' "$OUT1" | grep -q "greylist" &&
printf '%s\n' "$OUT1" | grep -q "ADDR SET" &&
printf '%s\n' "$OUT1" | grep -q "trusted" &&
printf '%s\n' "$OUT1" | grep -q "greylist" ; then
echo "ok 1 - show-firewall includes address-set summaries"
else
echo "not ok 1 - show-firewall missing address-set overview"
exit 1
fi
OUT2="$(cat "$JSON" | "$CLI" show-firewall-zone trusted | strip_ansi)"
if printf '%s\n' "$OUT2" | grep -q "address-sets" &&
printf '%s\n' "$OUT2" | grep -q "allowed, greylist" ; then
echo "ok 2 - show-firewall-zone shows zone address-sets"
exit 0
fi
echo "not ok 2 - show-firewall-zone missing zone address-sets"
exit 1
@@ -0,0 +1,61 @@
{
"infix-firewall:firewall": {
"default": "public",
"logging": "all",
"zone": [
{
"name": "mgmt",
"action": "accept",
"interface": ["e0"],
"service": ["ssh", "netconf", "restconf"]
},
{
"name": "public",
"action": "drop",
"interface": ["e1"]
},
{
"name": "trusted",
"action": "accept",
"address-set": ["allowed", "greylist"]
}
],
"address-set": [
{
"name": "allowed",
"family": "ipv4",
"current": [
{
"entry": "192.168.1.40",
"dynamic": false
},
{
"entry": "192.168.1.50",
"dynamic": true
}
]
},
{
"name": "greylist",
"family": "ipv4",
"timeout": 10,
"current": [
{
"entry": "192.168.1.60",
"dynamic": true,
"expires": 7
}
]
}
],
"policy": [
{
"name": "public-to-mgmt",
"action": "reject",
"priority": 100,
"ingress": ["public"],
"egress": ["mgmt"]
}
]
}
}
+1
View File
@@ -0,0 +1 @@
test.adoc
+39
View File
@@ -0,0 +1,39 @@
=== Firewall Address-Sets with Dynamic Entries
ifdef::topdoc[:imagesdir: {topdoc}../../test/case/firewall/address-set]
==== Description
Verifies firewall address-sets used as zone sources, in a setup where
all traffic from the data network is dropped by default and end devices
are granted access per-IP at runtime.
image::topology.svg[align=center, scaledwidth=50%]
- The default zone "public" drops all traffic on the data interface
- The "trusted" zone accepts traffic from members of the "allowed"
address-set: one static entry from the configuration, and dynamic
entries managed at runtime with the add/remove/flush actions
- Dynamic entries must survive unrelated firewall configuration
changes, but are not saved to the configuration
- Static entries cannot be removed with the remove action
- Entries in timeout sets ("greylist") expire on their own
==== Topology
image::topology.svg[Firewall Address-Sets with Dynamic Entries topology, align=center, scaledwidth=75%]
==== Sequence
. Set up topology and attach to target
. Configure firewall with address-set as zone source
. Verify host is blocked by default
. Add dynamic entry for host, verify it is allowed
. Verify operational state distinguishes static/dynamic
. Verify dynamic entry survives configuration change
. Verify static entry cannot be removed with action
. Remove dynamic entry, verify host is blocked
. Flush dynamic entries, static remain
. Verify entries in timeout set expire on their own
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""Firewall Address-Sets with Dynamic Entries
Verifies firewall address-sets used as zone sources, in a setup where
all traffic from the data network is dropped by default and end devices
are granted access per-IP at runtime.
image::topology.svg[align=center, scaledwidth=50%]
- The default zone "public" drops all traffic on the data interface
- The "trusted" zone accepts traffic from members of the "allowed"
address-set: one static entry from the configuration, and dynamic
entries managed at runtime with the add/remove/flush actions
- Dynamic entries must survive unrelated firewall configuration
changes, but are not saved to the configuration
- Static entries cannot be removed with the remove action
- Entries in timeout sets ("greylist") expire on their own
"""
import infamy
from infamy.util import until
def get_address_set(target, name):
"""Fetch operational state for a named address-set"""
data = target.get_data("/infix-firewall:firewall")
sets = data["firewall"].get("address-set", [])
return next((s for s in sets if s["name"] == name), None)
def current_entries(target, name):
"""Return {entry: dynamic} for the live contents of an address-set"""
aset = get_address_set(target, name)
if not aset:
return {}
return {c["entry"]: c["dynamic"] for c in aset.get("current", [])}
def must_fail(fn, *args):
"""Assert that an action call raises an error"""
try:
fn(*args)
except Exception:
return
raise AssertionError(f"{args} unexpectedly succeeded")
with infamy.Test() as test:
ALLOWED = "/infix-firewall:firewall/address-set[name='allowed']"
GREYLIST = "/infix-firewall:firewall/address-set[name='greylist']"
with test.step("Set up topology and attach to target"):
env = infamy.Env()
target = env.attach("target", "mgmt")
_, data_if = env.ltop.xlate("target", "data")
_, mgmt_if = env.ltop.xlate("target", "mgmt")
_, host_data = env.ltop.xlate("host", "data")
TARGET_IP = "192.168.1.1"
HOST_IP = "192.168.1.42"
STATIC_IP = "192.168.1.40"
EXTRA_IP = "192.168.1.50"
with test.step("Configure firewall with address-set as zone source"):
target.put_config_dicts({
"ietf-interfaces": {
"interfaces": {
"interface": [{
"name": data_if,
"enabled": True,
"ipv4": {
"address": [{
"ip": TARGET_IP,
"prefix-length": 24
}]
}
}]
}
},
"infix-firewall": {
"firewall": {
"default": "public",
"logging": "all",
"address-set": [{
"name": "allowed",
"description": "End devices allowed to access the target",
"entry": [STATIC_IP]
}, {
"name": "greylist",
"description": "Entries expire on their own",
"timeout": 10
}],
"zone": [{
"name": "mgmt",
"description": "Management network - for test automation",
"action": "accept",
"interface": [mgmt_if],
"service": ["ssh", "netconf", "restconf"]
}, {
"name": "public",
"description": "Untrusted data network, drop everything",
"action": "drop",
"interface": [data_if]
}, {
"name": "trusted",
"description": "Allowed end devices",
"action": "accept",
"address-set": ["allowed"]
}]
}
}
})
infamy.Firewall.wait_for_operational(target, {
"public": {"action": "drop"},
"trusted": {"action": "accept"},
"mgmt": {"action": "accept"}
})
aset = get_address_set(target, "allowed")
assert aset, "Address-set 'allowed' not found in operational"
assert STATIC_IP in aset.get("entry", []), \
f"Static entry {STATIC_IP} missing from configuration"
with infamy.IsolatedMacVlan(host_data) as ns:
ns.addip(HOST_IP)
with test.step("Verify host is blocked by default"):
ns.must_not_reach(TARGET_IP, timeout=5)
with test.step("Add dynamic entry for host, verify it is allowed"):
target.call_action(f"{ALLOWED}/add", {"entry": HOST_IP})
ns.must_reach(TARGET_IP, timeout=10)
with test.step("Verify operational state distinguishes static/dynamic"):
def entries_settled():
current = current_entries(target, "allowed")
return current.get(STATIC_IP) is False and \
current.get(HOST_IP) is True
until(entries_settled, attempts=10)
with test.step("Verify dynamic entry survives configuration change"):
target.put_config_dicts({
"infix-firewall": {
"firewall": {
"logging": "unicast"
}
}
})
ns.must_reach(TARGET_IP, timeout=30)
current = current_entries(target, "allowed")
assert current.get(HOST_IP) is True, \
f"Dynamic entry {HOST_IP} lost in firewall reload"
with test.step("Verify static entry cannot be removed with action"):
must_fail(target.call_action, f"{ALLOWED}/remove",
{"entry": STATIC_IP})
with test.step("Remove dynamic entry, verify host is blocked"):
target.call_action(f"{ALLOWED}/remove", {"entry": HOST_IP})
ns.must_not_reach(TARGET_IP, timeout=5)
with test.step("Flush dynamic entries, static remain"):
target.call_action(f"{ALLOWED}/add", {"entry": HOST_IP})
target.call_action(f"{ALLOWED}/add", {"entry": EXTRA_IP})
target.call_action(f"{ALLOWED}/flush")
def only_static_left():
current = current_entries(target, "allowed")
return list(current.keys()) == [STATIC_IP]
until(only_static_left, attempts=10)
ns.must_not_reach(TARGET_IP, timeout=5)
with test.step("Verify entries in timeout set expire on their own"):
target.call_action(f"{GREYLIST}/add", {"entry": "10.0.0.1"})
aset = get_address_set(target, "greylist")
assert aset, "Address-set 'greylist' not found in operational"
current = {c["entry"]: c for c in aset.get("current", [])}
assert "10.0.0.1" in current, "Entry missing from timeout set"
assert current["10.0.0.1"].get("expires") is not None, \
"Entry in timeout set has no expiry"
must_fail(target.call_action, f"{GREYLIST}/remove",
{"entry": "10.0.0.1"})
def entry_expired():
return "10.0.0.1" not in current_entries(target, "greylist")
until(entry_expired, attempts=20)
test.succeed()
@@ -0,0 +1,23 @@
graph "1x2" {
layout = "neato";
overlap = false;
esep = "+30";
node [shape=record, fontname="DejaVu Sans Mono, Book"];
edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"];
host [
label="host | { <mgmt> mgmt | <data> data }",
pos="10,10!",
requires="controller"
];
target [
label="{ <mgmt> mgmt | <data> data } | target",
pos="40,10!",
requires="infix",
];
host:mgmt -- target:mgmt [requires="mgmt", color="lightgray"]
host:data -- target:data [color=black]
}
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Title: 1x2 Pages: 1 -->
<svg width="299pt" height="55pt"
viewBox="0.00 0.00 299.02 55.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 51)">
<title>1x2</title>
<polygon fill="white" stroke="transparent" points="-4,4 -4,-51 295.02,-51 295.02,4 -4,4"/>
<!-- host -->
<g id="node1" class="node">
<title>host</title>
<polygon fill="none" stroke="black" points="0,-0.5 0,-46.5 100,-46.5 100,-0.5 0,-0.5"/>
<text text-anchor="middle" x="25" y="-19.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">host</text>
<polyline fill="none" stroke="black" points="50,-0.5 50,-46.5 "/>
<text text-anchor="middle" x="75" y="-31.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
<polyline fill="none" stroke="black" points="50,-23.5 100,-23.5 "/>
<text text-anchor="middle" x="75" y="-8.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">data</text>
</g>
<!-- target -->
<g id="node2" class="node">
<title>target</title>
<polygon fill="none" stroke="black" points="175.02,-0.5 175.02,-46.5 291.02,-46.5 291.02,-0.5 175.02,-0.5"/>
<text text-anchor="middle" x="200.02" y="-31.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
<polyline fill="none" stroke="black" points="175.02,-23.5 225.02,-23.5 "/>
<text text-anchor="middle" x="200.02" y="-8.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">data</text>
<polyline fill="none" stroke="black" points="225.02,-0.5 225.02,-46.5 "/>
<text text-anchor="middle" x="258.02" y="-19.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">target</text>
</g>
<!-- host&#45;&#45;target -->
<g id="edge1" class="edge">
<title>host:mgmt&#45;&#45;target:mgmt</title>
<path fill="none" stroke="lightgray" stroke-width="2" d="M100,-35.5C100,-35.5 175.02,-35.5 175.02,-35.5"/>
</g>
<!-- host&#45;&#45;target -->
<g id="edge2" class="edge">
<title>host:data&#45;&#45;target:data</title>
<path fill="none" stroke="black" stroke-width="2" d="M100,-11.5C100,-11.5 175.02,-11.5 175.02,-11.5"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+3
View File
@@ -2,6 +2,9 @@
- name: Basic Firewall for End Devices
case: basic/test.py
- name: Firewall Address-Sets with Dynamic Entries
case: address-set/test.py
- name: LAN-WAN Firewall with Masquerading
case: lan-wan/test.py