mirror of
https://github.com/kernelkit/infix.git
synced 2026-07-22 01:13:00 +02:00
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:
@@ -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
|
||||
-------------------------
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -37,3 +37,6 @@
|
||||
- "json/bloated.json"
|
||||
- "show-interfaces"
|
||||
- "-n br0"
|
||||
|
||||
- case: firewall_overview.sh
|
||||
name: "firewall-overview"
|
||||
|
||||
Executable
+34
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
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
|
||||
|
||||
|
||||
Executable
+194
@@ -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--target -->
|
||||
<g id="edge1" class="edge">
|
||||
<title>host:mgmt--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--target -->
|
||||
<g id="edge2" class="edge">
|
||||
<title>host:data--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 |
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user