diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 1611a361..ddc5bf72 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -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 ------------------------- diff --git a/doc/firewall.md b/doc/firewall.md index 1f33052a..692cf4c5 100644 --- a/doc/firewall.md +++ b/doc/firewall.md @@ -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. + +
admin@example:/> configure
+admin@example:/config/> edit firewall address-set allowed
+admin@example:/config/firewall/…/allowed/> set description "End devices granted access"
+admin@example:/config/firewall/…/allowed/> set entry 192.168.1.40
+admin@example:/config/firewall/…/allowed/> end
+admin@example:/config/firewall/> edit zone trusted
+admin@example:/config/firewall/…/trusted/> set action accept
+admin@example:/config/firewall/…/trusted/> set address-set allowed
+admin@example:/config/firewall/…/trusted/> leave
+
+ +### 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: + +
admin@example:/> firewall address-set allowed add 192.168.1.42
+admin@example:/> show firewall address-set allowed
+name                : allowed
+family              : ipv4
+timeout             : none
+
+ENTRY            TYPE     EXPIRES
+192.168.1.40     static
+192.168.1.42     dynamic
+admin@example:/> firewall address-set allowed remove 192.168.1.42
+
+ +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. + +
admin@example:/config/firewall/> edit address-set banned
+admin@example:/config/firewall/…/banned/> set timeout 3600
+admin@example:/config/firewall/…/banned/> leave
+admin@example:/> firewall address-set banned add 203.0.113.99
+
+ +The remaining lifetime of each entry is shown in the `EXPIRES` column of +show firewall address-set. + +> [!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 diff --git a/src/confd/bin/firewall b/src/confd/bin/firewall index 055b9096..28a9834d 100755 --- a/src/confd/bin/firewall +++ b/src/confd/bin/firewall @@ -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: + ipset CMD SET ENTRY Runtime ipset operation: 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 ;; diff --git a/src/confd/src/firewall.c b/src/confd/src/firewall.c index ba6969e8..be6a1831 100644 --- a/src/confd/src/firewall.c +++ b/src/confd/src/firewall.c @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -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, " \n", lyd_get_value(node)); + LYX_LIST_FOR_EACH(lyd_child(cfg), node, "address-set") + fprintf(fp, " \n", lyd_get_value(node)); + LYX_LIST_FOR_EACH(lyd_child(cfg), node, "service") fprintf(fp, " \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, " %s\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, "\n"); + fprintf(fp, " %s\n", name); + + if (desc) + fprintf(fp, " %s\n", desc); + + fprintf(fp, " \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: diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc index e740c6cd..2ad8291c 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -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" diff --git a/src/confd/yang/confd/infix-firewall.yang b/src/confd/yang/confd/infix-firewall.yang index ca2119db..4624abb0 100644 --- a/src/confd/yang/confd/infix-firewall.yang +++ b/src/confd/yang/confd/infix-firewall.yang @@ -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; diff --git a/src/confd/yang/confd/infix-firewall@2025-04-26.yang b/src/confd/yang/confd/infix-firewall@2026-07-02.yang similarity index 100% rename from src/confd/yang/confd/infix-firewall@2025-04-26.yang rename to src/confd/yang/confd/infix-firewall@2026-07-02.yang diff --git a/src/klish-plugin-infix/src/infix.c b/src/klish-plugin-infix/src/infix.c index 01fc8f9c..01bd4830 100644 --- a/src/klish-plugin-infix/src/infix.c +++ b/src/klish-plugin-infix/src/infix.c @@ -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)); diff --git a/src/klish-plugin-infix/xml/infix.xml b/src/klish-plugin-infix/xml/infix.xml index bfb3b9b3..99f6f889 100644 --- a/src/klish-plugin-infix/xml/infix.xml +++ b/src/klish-plugin-infix/xml/infix.xml @@ -134,6 +134,13 @@ + + + + + + + @@ -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 + + + + + + copy operational -x /infix-firewall:firewall | /usr/libexec/statd/cli-pretty show-firewall-address-set "$KLISH_PARAM_name" |pager + + copy operational -x /infix-firewall:firewall | /usr/libexec/statd/cli-pretty show-firewall |pager @@ -826,6 +841,22 @@ echo "Public: $pub" + + + + + + + + + + + + + + + + diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index b4c5b34e..9a58a7ee 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -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": diff --git a/src/statd/python/yanger/infix_firewall.py b/src/statd/python/yanger/infix_firewall.py index b06746ae..cb5b4fc3 100644 --- a/src/statd/python/yanger/infix_firewall.py +++ b/src/statd/python/yanger/infix_firewall.py @@ -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 diff --git a/src/webui/internal/handlers/configure_firewall.go b/src/webui/internal/handlers/configure_firewall.go index 1f477dfe..72cc1f8b 100644 --- a/src/webui/internal/handlers/configure_firewall.go +++ b/src/webui/internal/handlers/configure_firewall.go @@ -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). diff --git a/src/webui/internal/handlers/configure_firewall_test.go b/src/webui/internal/handlers/configure_firewall_test.go new file mode 100644 index 00000000..518fecd2 --- /dev/null +++ b/src/webui/internal/handlers/configure_firewall_test.go @@ -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) + } +} diff --git a/src/webui/internal/handlers/firewall.go b/src/webui/internal/handlers/firewall.go index 9d2737ac..c4abc17c 100644 --- a/src/webui/internal/handlers/firewall.go +++ b/src/webui/internal/handlers/firewall.go @@ -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, diff --git a/src/webui/internal/server/server.go b/src/webui/internal/server/server.go index c8f20ef3..b02bc8a5 100644 --- a/src/webui/internal/server/server.go +++ b/src/webui/internal/server/server.go @@ -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) diff --git a/src/webui/templates/pages/configure-firewall.html b/src/webui/templates/pages/configure-firewall.html index 011798bb..234cf73e 100644 --- a/src/webui/templates/pages/configure-firewall.html +++ b/src/webui/templates/pages/configure-firewall.html @@ -112,6 +112,7 @@ Action{{template "field-info" (index $d "zone-action")}} Description{{template "field-info" (index $d "zone-description")}} Interfaces + Address sets{{template "field-info" (index $d "zone-address-set")}} @@ -128,6 +129,7 @@ {{if .Action}}{{.Action}}{{else}}reject{{end}} {{if .Description}}{{.Description}}{{else}}{{end}} {{.IfaceCount}} + {{if .AddressSet}}{{.AddrSetsTxt}}{{else}}{{end}} {{if not .Immutable}} + + + {{end}} @@ -731,4 +906,3 @@ {{end}}{{/* end {{else}} — schema ready */}} {{end}} - diff --git a/src/webui/templates/pages/firewall.html b/src/webui/templates/pages/firewall.html index 41701d20..2ae2f87d 100644 --- a/src/webui/templates/pages/firewall.html +++ b/src/webui/templates/pages/firewall.html @@ -90,6 +90,7 @@ Action Interfaces Networks + Address Sets Host Services @@ -100,6 +101,7 @@ {{.Action}} {{.Interfaces}} {{if .Networks}}{{.Networks}}{{else}}{{end}} + {{if .AddressSets}}{{.AddressSets}}{{else}}{{end}} {{if .Services}}{{.Services}}{{else}}{{end}} {{end}} @@ -109,6 +111,40 @@ {{end}} + {{if .AddressSets}} +
+
Address Sets
+
+ + + + + + + + + + + {{range .AddressSets}} + + + + + + + {{end}} + +
NameFamilyTimeoutEntries
{{.Name}}{{.Family}}{{if .Timeout}}{{.Timeout}}{{else}}{{end}} + {{if .Entries}} + {{range $i, $e := .Entries}}{{if $i}}, {{end}}{{.Entry}}{{if .Dynamic}} dyn{{if .Expires}} {{.Expires}}{{end}}{{end}}{{end}} + {{else}} + (empty) + {{end}} +
+
+
+ {{end}} + {{if .Policies}}
Policies
diff --git a/test/case/cli_pretty/all.yaml b/test/case/cli_pretty/all.yaml index 64de1bf6..b56be0ee 100644 --- a/test/case/cli_pretty/all.yaml +++ b/test/case/cli_pretty/all.yaml @@ -37,3 +37,6 @@ - "json/bloated.json" - "show-interfaces" - "-n br0" + +- case: firewall_overview.sh + name: "firewall-overview" diff --git a/test/case/cli_pretty/firewall_overview.sh b/test/case/cli_pretty/firewall_overview.sh new file mode 100755 index 00000000..ad12a345 --- /dev/null +++ b/test/case/cli_pretty/firewall_overview.sh @@ -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 diff --git a/test/case/cli_pretty/json/firewall-overview.json b/test/case/cli_pretty/json/firewall-overview.json new file mode 100644 index 00000000..6ce93921 --- /dev/null +++ b/test/case/cli_pretty/json/firewall-overview.json @@ -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"] + } + ] + } +} diff --git a/test/case/firewall/address-set/Readme.adoc b/test/case/firewall/address-set/Readme.adoc new file mode 120000 index 00000000..ae32c841 --- /dev/null +++ b/test/case/firewall/address-set/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/firewall/address-set/test.adoc b/test/case/firewall/address-set/test.adoc new file mode 100644 index 00000000..472014d4 --- /dev/null +++ b/test/case/firewall/address-set/test.adoc @@ -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 + + diff --git a/test/case/firewall/address-set/test.py b/test/case/firewall/address-set/test.py new file mode 100755 index 00000000..7ed809d6 --- /dev/null +++ b/test/case/firewall/address-set/test.py @@ -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() diff --git a/test/case/firewall/address-set/topology.dot b/test/case/firewall/address-set/topology.dot new file mode 100644 index 00000000..84948360 --- /dev/null +++ b/test/case/firewall/address-set/topology.dot @@ -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 | data }", + pos="10,10!", + requires="controller" + ]; + + target [ + label="{ mgmt | data } | target", + pos="40,10!", + requires="infix", + ]; + + host:mgmt -- target:mgmt [requires="mgmt", color="lightgray"] + host:data -- target:data [color=black] +} diff --git a/test/case/firewall/address-set/topology.svg b/test/case/firewall/address-set/topology.svg new file mode 100644 index 00000000..35a4d0ca --- /dev/null +++ b/test/case/firewall/address-set/topology.svg @@ -0,0 +1,42 @@ + + + + + + +1x2 + + + +host + +host + +mgmt + +data + + + +target + +mgmt + +data + +target + + + +host:mgmt--target:mgmt + + + + +host:data--target:data + + + + diff --git a/test/case/firewall/all.yaml b/test/case/firewall/all.yaml index 2778a31b..008c31c2 100644 --- a/test/case/firewall/all.yaml +++ b/test/case/firewall/all.yaml @@ -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