From 770ca373695761903723c34e2af1b5a8033e3c01 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 14 Jul 2025 19:44:12 +0200 Subject: [PATCH 01/55] package/finit: bump to v4.13 Highlights: - fixes to systemd and s6 type services - bare-bones libsystemd replacement with #include - new reload:script mimicking systemd ExecReload, and - new stop:script mimicking systemd ExecStop Full changelog at: Signed-off-by: Joachim Wiberg --- package/finit/finit.hash | 4 ++-- package/finit/finit.mk | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/package/finit/finit.hash b/package/finit/finit.hash index 9f2bd7594..cdb2a984a 100644 --- a/package/finit/finit.hash +++ b/package/finit/finit.hash @@ -1,8 +1,8 @@ # From https://github.com/troglobit/finit/releases/ -sha256 b6a0a2f98c860cf9fe5dfe7e3601d922957ad7880ae29919176ab960b7b96e70 finit-4.12.tar.gz +sha256 d49c9b9253c54c958da75d41436c2845218fa2bb20248a319e931d924b3ba6cb finit-4.13.tar.gz # Locally calculated -sha256 2fd62c0fe6ea6d1861669f4c87bda83a0b5ceca64f4baa4d16dd078fbd218c14 LICENSE +sha256 868cb6c5414933a48db11186042cfe65c87480d326734bc6cf0e4b19b4a2e52a LICENSE # GIT Snapshot sha256 8c880293409cf566f6256bff193f985c50bd2eb99d2ff964dcaa9590251ed27e finit-438d6b4e638418a2a22024a3cead2f47909d72b9.tar.gz diff --git a/package/finit/finit.mk b/package/finit/finit.mk index 50959feac..581049515 100644 --- a/package/finit/finit.mk +++ b/package/finit/finit.mk @@ -4,7 +4,7 @@ # ################################################################################ -FINIT_VERSION = 4.12 +FINIT_VERSION = 4.13 FINIT_SITE = https://github.com/troglobit/finit/releases/download/$(FINIT_VERSION) FINIT_LICENSE = MIT FINIT_LICENSE_FILES = LICENSE @@ -39,6 +39,7 @@ FINIT_CONF_OPTS = \ --disable-contrib \ --disable-rescue \ --disable-silent-rules \ + --without-libsystemd \ --with-group="$(FINIT_GROUP)" ifeq ($(BR2_ROOTFS_MERGED_USR),y) From a9cbdb346410699ad0dfc449f9507f1b4e872e84 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 15 Jul 2025 06:58:33 +0200 Subject: [PATCH 02/55] cli: add terminal reset|resize commands Signed-off-by: Joachim Wiberg --- src/klish-plugin-infix/xml/infix.xml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/klish-plugin-infix/xml/infix.xml b/src/klish-plugin-infix/xml/infix.xml index 1ce5ff1a4..f1421986c 100644 --- a/src/klish-plugin-infix/xml/infix.xml +++ b/src/klish-plugin-infix/xml/infix.xml @@ -555,6 +555,19 @@ + + + + reset + + + + + resize >/dev/null + + + + From 45ddae55f5b4e012af558ccf36e09c0cb59492d3 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 5 Aug 2025 07:56:19 +0200 Subject: [PATCH 03/55] board/common: fixes for unicode translation in log/pager commands Signed-off-by: Joachim Wiberg --- board/common/rootfs/etc/bash.bashrc | 4 ++-- board/common/rootfs/usr/bin/pager | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/board/common/rootfs/etc/bash.bashrc b/board/common/rootfs/etc/bash.bashrc index 34bbacf8c..a8835e833 100644 --- a/board/common/rootfs/etc/bash.bashrc +++ b/board/common/rootfs/etc/bash.bashrc @@ -19,14 +19,14 @@ log() { local fn="/var/log/syslog" [ -n "$1" ] && fn="/var/log/$1" - less +G "$fn" + less +G -r "$fn" } follow() { local fn="/var/log/syslog" [ -n "$1" ] && fn="/var/log/$1" - tail -F "$fn" + less +F -r "$fn" } _logfile_completions() diff --git a/board/common/rootfs/usr/bin/pager b/board/common/rootfs/usr/bin/pager index cea4c3ecb..ac5cd8ed3 100755 --- a/board/common/rootfs/usr/bin/pager +++ b/board/common/rootfs/usr/bin/pager @@ -5,10 +5,11 @@ # -K :: exit immediately when an interrupt character (usually ^C) is typed # -R :: Almost raw control charachters, only ANSI color escape sequences and # OSC 8 hyperlink sequences are output. Allows veritcal scrolling +# -r :: Causes "raw" control characters to be displayed, including unicode. # -X :: No termcap initialization and deinitialization set to the terminal. # This is what leaves the contents of the output on screen. export LESS="-P %f (press h for help or q to quit)" export LANG=en_US.UTF-8 -less -RIKd -FX "$@" +less -rIKd -FX "$@" From c9037220ea87b92b720293415038654e7ff091dd Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 4 Aug 2025 21:27:09 +0200 Subject: [PATCH 04/55] confd: initial firewall support Signed-off-by: Joachim Wiberg --- .../etc/finit.d/available/firewalld.conf | 3 + configs/aarch64_defconfig | 1 + configs/aarch64_minimal_defconfig | 1 + configs/r2s_defconfig | 1 + configs/riscv64_defconfig | 1 + configs/x86_64_defconfig | 1 + configs/x86_64_minimal_defconfig | 1 + package/confd/Config.in | 1 + package/confd/confd.mk | 7 +- src/confd/src/Makefile.am | 1 + src/confd/src/core.c | 3 + src/confd/src/core.h | 3 + src/confd/src/infix-firewall.c | 399 ++++++++++++++++ src/confd/yang/confd.inc | 1 + src/confd/yang/confd/infix-firewall.yang | 431 ++++++++++++++++++ .../yang/confd/infix-firewall@2025-04-26.yang | 1 + 16 files changed, 855 insertions(+), 1 deletion(-) create mode 100644 board/common/rootfs/etc/finit.d/available/firewalld.conf create mode 100644 src/confd/src/infix-firewall.c create mode 100644 src/confd/yang/confd/infix-firewall.yang create mode 120000 src/confd/yang/confd/infix-firewall@2025-04-26.yang diff --git a/board/common/rootfs/etc/finit.d/available/firewalld.conf b/board/common/rootfs/etc/finit.d/available/firewalld.conf new file mode 100644 index 000000000..0b7597a3b --- /dev/null +++ b/board/common/rootfs/etc/finit.d/available/firewalld.conf @@ -0,0 +1,3 @@ +service [2345] reload:'firewall-cmd --reload' \ + firewalld --nofork --log-target syslog \ + -- Firewall daemon diff --git a/configs/aarch64_defconfig b/configs/aarch64_defconfig index db394c516..6cfe97d02 100644 --- a/configs/aarch64_defconfig +++ b/configs/aarch64_defconfig @@ -71,6 +71,7 @@ BR2_PACKAGE_CONNTRACK_TOOLS=y BR2_PACKAGE_DNSMASQ=y BR2_PACKAGE_ETHTOOL=y BR2_PACKAGE_FPING=y +BR2_PACKAGE_FIREWALLD=y BR2_PACKAGE_FRR=y # BR2_PACKAGE_IFUPDOWN_SCRIPTS is not set BR2_PACKAGE_IPROUTE2=y diff --git a/configs/aarch64_minimal_defconfig b/configs/aarch64_minimal_defconfig index ecc5f053a..39e432df0 100644 --- a/configs/aarch64_minimal_defconfig +++ b/configs/aarch64_minimal_defconfig @@ -67,6 +67,7 @@ BR2_PACKAGE_AVAHI_DEFAULT_SERVICES=y BR2_PACKAGE_CHRONY=y BR2_PACKAGE_DNSMASQ=y BR2_PACKAGE_ETHTOOL=y +BR2_PACKAGE_FIREWALLD=y BR2_PACKAGE_FRR=y # BR2_PACKAGE_IFUPDOWN_SCRIPTS is not set BR2_PACKAGE_IPROUTE2=y diff --git a/configs/r2s_defconfig b/configs/r2s_defconfig index 6067d7769..8d411c8a9 100644 --- a/configs/r2s_defconfig +++ b/configs/r2s_defconfig @@ -89,6 +89,7 @@ BR2_PACKAGE_CONNTRACK_TOOLS=y BR2_PACKAGE_DNSMASQ=y BR2_PACKAGE_ETHTOOL=y BR2_PACKAGE_FPING=y +BR2_PACKAGE_FIREWALLD=y BR2_PACKAGE_FRR=y # BR2_PACKAGE_IFUPDOWN_SCRIPTS is not set BR2_PACKAGE_IPROUTE2=y diff --git a/configs/riscv64_defconfig b/configs/riscv64_defconfig index 8aca79ed1..6abe45ca5 100644 --- a/configs/riscv64_defconfig +++ b/configs/riscv64_defconfig @@ -83,6 +83,7 @@ BR2_PACKAGE_CONNTRACK_TOOLS=y BR2_PACKAGE_DNSMASQ=y BR2_PACKAGE_ETHTOOL=y BR2_PACKAGE_FPING=y +BR2_PACKAGE_FIREWALLD=y BR2_PACKAGE_FRR=y # BR2_PACKAGE_IFUPDOWN_SCRIPTS is not set BR2_PACKAGE_IPROUTE2=y diff --git a/configs/x86_64_defconfig b/configs/x86_64_defconfig index 54bb314b6..13c92d18a 100644 --- a/configs/x86_64_defconfig +++ b/configs/x86_64_defconfig @@ -69,6 +69,7 @@ BR2_PACKAGE_CONNTRACK_TOOLS=y BR2_PACKAGE_DNSMASQ=y BR2_PACKAGE_ETHTOOL=y BR2_PACKAGE_FPING=y +BR2_PACKAGE_FIREWALLD=y BR2_PACKAGE_FRR=y # BR2_PACKAGE_IFUPDOWN_SCRIPTS is not set BR2_PACKAGE_IPROUTE2=y diff --git a/configs/x86_64_minimal_defconfig b/configs/x86_64_minimal_defconfig index 37b200471..3ac9e55c5 100644 --- a/configs/x86_64_minimal_defconfig +++ b/configs/x86_64_minimal_defconfig @@ -65,6 +65,7 @@ BR2_PACKAGE_AVAHI_DEFAULT_SERVICES=y BR2_PACKAGE_CHRONY=y BR2_PACKAGE_DNSMASQ=y BR2_PACKAGE_ETHTOOL=y +BR2_PACKAGE_FIREWALLD=y BR2_PACKAGE_FRR=y # BR2_PACKAGE_IFUPDOWN_SCRIPTS is not set BR2_PACKAGE_IPROUTE2=y diff --git a/package/confd/Config.in b/package/confd/Config.in index 984d7cdab..adb30e78c 100644 --- a/package/confd/Config.in +++ b/package/confd/Config.in @@ -5,6 +5,7 @@ config BR2_PACKAGE_CONFD select BR2_PACKAGE_NETOPEER2 select BR2_PACKAGE_SYSREPO select BR2_PACKAGE_LIBSRX + select BR2_PACKAGE_FIREWALLD help A plugin to sysrepo that provides the core YANG models used to manage an Infix based system. Configuration can be done using diff --git a/package/confd/confd.mk b/package/confd/confd.mk index 96024a1f0..2f5e10c41 100644 --- a/package/confd/confd.mk +++ b/package/confd/confd.mk @@ -10,7 +10,7 @@ CONFD_SITE = $(BR2_EXTERNAL_INFIX_PATH)/src/confd CONFD_LICENSE = BSD-3-Clause CONFD_LICENSE_FILES = LICENSE CONFD_REDISTRIBUTE = NO -CONFD_DEPENDENCIES = host-sysrepo sysrepo netopeer2 jansson libite sysrepo libsrx libglib2 +CONFD_DEPENDENCIES = host-sysrepo sysrepo netopeer2 jansson libite sysrepo libsrx libglib2 firewalld CONFD_AUTORECONF = YES CONFD_CONF_OPTS += --disable-silent-rules --with-crypt=$(BR2_PACKAGE_CONFD_DEFAULT_CRYPT) CONFD_SYSREPO_SHM_PREFIX = sr_buildroot$(subst /,_,$(CONFIG_DIR))_confd @@ -102,6 +102,11 @@ define CONFD_EMPTY_SYSREPO endef define CONFD_CLEANUP rm -f /dev/shm/$(CONFD_SYSREPO_SHM_PREFIX)* + rm -rf $(TARGET_DIR)/etc/firewall* + rm -f $(TARGET_DIR)/usr/bin/firewall-applet + rm -rf $(TARGET_DIR)/usr/share/firewalld + mkdir -p $(TARGET_DIR)/etc/firewalld + touch $(TARGET_DIR)/etc/firewalld/firewalld.conf endef CONFD_PRE_BUILD_HOOKS += CONFD_EMPTY_SYSREPO CONFD_PRE_BUILD_HOOKS += CONFD_CLEANUP diff --git a/src/confd/src/Makefile.am b/src/confd/src/Makefile.am index 87beb5920..29466e992 100644 --- a/src/confd/src/Makefile.am +++ b/src/confd/src/Makefile.am @@ -47,6 +47,7 @@ confd_plugin_la_SOURCES = \ infix-dhcp-client.c \ infix-dhcp-server.c \ infix-factory.c \ + infix-firewall.c \ infix-meta.c \ infix-services.c \ infix-system-software.c \ diff --git a/src/confd/src/core.c b/src/confd/src/core.c index ba987f4b0..49fb59fe7 100644 --- a/src/confd/src/core.c +++ b/src/confd/src/core.c @@ -172,6 +172,9 @@ int sr_plugin_init_cb(sr_session_ctx_t *session, void **priv) if (rc) goto err; rc = ietf_hardware_init(&confd); + if (rc) + goto err; + rc = infix_firewall_init(&confd); if (rc) goto err; diff --git a/src/confd/src/core.h b/src/confd/src/core.h index ffe08309d..90595595c 100644 --- a/src/confd/src/core.h +++ b/src/confd/src/core.h @@ -256,4 +256,7 @@ int ietf_hardware_init(struct confd *confd); /* ietf-keystore.c */ int ietf_keystore_init(struct confd *confd); +/* infix-firewall.c */ +int infix_firewall_init(struct confd *confd); + #endif /* CONFD_CORE_H_ */ diff --git a/src/confd/src/infix-firewall.c b/src/confd/src/infix-firewall.c new file mode 100644 index 000000000..33a8e23be --- /dev/null +++ b/src/confd/src/infix-firewall.c @@ -0,0 +1,399 @@ +/* SPDX-License-Identifier: BSD-3-Clause */ + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "core.h" + +#define MODULE "infix-firewall" +#define CFG_XPATH "/infix-firewall:firewall" + +#define FIREWALLD_DIR "/etc/firewalld" +#define FIREWALLD_CONF FIREWALLD_DIR "/firewalld.conf" +#define FIREWALLD_ZONES_DIR FIREWALLD_DIR "/zones" +#define FIREWALLD_SERVICES_DIR FIREWALLD_DIR "/services" +#define FIREWALLD_POLICIES_DIR FIREWALLD_DIR "/policies" + +static struct { + const char *yang; + const char *target; +} zone_policy_map[] = { + { "reject", "%%REJECT%%" }, + { "accept", "ACCEPT" }, + { "drop", "DROP" }, +}; + +static struct { + const char *yang; + const char *target; +} policy_action_map[] = { + { "continue", "CONTINUE" }, + { "accept", "ACCEPT" }, + { "reject", "REJECT" }, + { "drop", "DROP" }, +}; + +static const char *zone_policy_to_target(const char *policy) +{ + for (size_t i = 0; policy && i < NELEMS(zone_policy_map); i++) { + if (!strcmp(policy, zone_policy_map[i].yang)) + return zone_policy_map[i].target; + } + + return zone_policy_map[0].yang; +} + +static const char *policy_action_to_target(const char *action) +{ + for (size_t i = 0; action && i < NELEMS(policy_action_map); i++) { + if (!strcmp(action, policy_action_map[i].yang)) + return policy_action_map[i].target; + } + + return policy_action_map[0].yang; +} + +static FILE *open_file(const char *dir, const char *name) +{ + FILE *fp; + + fp = fopenf("w", "%s/%s.xml", dir, name); + if (!fp) { + ERRNO("Failed creating %s/%s.xml: %s", dir, name, strerror(errno)); + return NULL; + } + + fprintf(fp, "\n"); + return fp; +} + +static int close_file(FILE *fp) +{ + fclose(fp); + return SR_ERR_OK; +} + +static int delete_file(const char *dir, const char *name) +{ + if (erasef("%s/%s.xml", dir, name) && errno != ENOENT) { + ERRNO("Failed deleting %s/%s.xml: %s", dir, name, strerror(errno)); + return SR_ERR_SYS; + } + + return SR_ERR_OK; +} + +static int generate_zone(const char *name, struct lyd_node *cfg) +{ + const char *policy, *desc; + struct lyd_node *node; + FILE *fp; + + fp = open_file(FIREWALLD_ZONES_DIR, name); + if (!fp) + return SR_ERR_SYS; + + policy = lydx_get_cattr(cfg, "policy"); + desc = lydx_get_cattr(cfg, "description"); + + fprintf(fp, "\n", zone_policy_to_target(policy)); + fprintf(fp, " %s", name); + if (desc) + fprintf(fp, " %s\n", desc); + + LYX_LIST_FOR_EACH(lyd_child(cfg), node, "interfaces") + fprintf(fp, " \n", lyd_get_value(node)); + + LYX_LIST_FOR_EACH(lyd_child(cfg), node, "sources") + fprintf(fp, " \n", lyd_get_value(node)); + + LYX_LIST_FOR_EACH(lyd_child(cfg), node, "services") + fprintf(fp, " \n", lyd_get_value(node)); + + LYX_LIST_FOR_EACH(lyd_child(cfg), node, "forward") { + const char *port = lydx_get_cattr(node, "port"); + const char *proto = lydx_get_cattr(node, "proto"); + struct lyd_node *to = lydx_get_child(node, "to"); + + if (to) { + const char *to_addr = lydx_get_cattr(to, "addr"); + const char *to_port = lydx_get_cattr(to, "port"); + + fprintf(fp, " \n"); + } + } + + LYX_LIST_FOR_EACH(lyd_child(cfg), node, "icmp-blocks") { + const char *icmp_type = lydx_get_cattr(node, "icmp-type"); + fprintf(fp, " \n", icmp_type); + } + + if (lydx_is_enabled(cfg, "forwarding")) + fprintf(fp, " \n"); + if (lydx_is_enabled(cfg, "masquerade")) + fprintf(fp, " \n"); + + fprintf(fp, "\n"); + + return close_file(fp); +} + +static int generate_service(const char *name, struct lyd_node *service_cfg) +{ + const char *desc, *destination; + struct lyd_node *node; + FILE *fp; + + fp = open_file(FIREWALLD_SERVICES_DIR, name); + if (!fp) + return SR_ERR_SYS; + + desc = lydx_get_cattr(service_cfg, "description"); + destination = lydx_get_cattr(service_cfg, "destination"); + + fprintf(fp, "\n"); + + if (desc) + fprintf(fp, " %s\n", desc); + + if (destination) + fprintf(fp, " \n", destination); + + LYX_LIST_FOR_EACH(lyd_child(service_cfg), node, "port") { + const char *lower = lydx_get_cattr(node, "lower"); + const char *upper = lydx_get_cattr(node, "upper"); + const char *proto = lydx_get_cattr(node, "proto"); + + if (upper && strcmp(lower, upper)) + fprintf(fp, " \n", lower, upper, proto); + else + fprintf(fp, " \n", lower, proto); + } + + fprintf(fp, "\n"); + + return close_file(fp); +} + +static int generate_policy(const char *name, struct lyd_node *policy_cfg) +{ + const char *desc, *policy; + struct lyd_node *node; + bool masquerade; + FILE *fp; + + fp = open_file(FIREWALLD_POLICIES_DIR, name); + if (!fp) + return SR_ERR_SYS; + + desc = lydx_get_cattr(policy_cfg, "description"); + policy = lydx_get_cattr(policy_cfg, "policy"); + masquerade = lydx_is_enabled(policy_cfg, "masquerade"); + + fprintf(fp, "\n", policy_action_to_target(policy)); + + if (desc) + fprintf(fp, " %s\n", desc); + + LYX_LIST_FOR_EACH(lyd_child(policy_cfg), node, "ingress") + fprintf(fp, " \n", lyd_get_value(node)); + + LYX_LIST_FOR_EACH(lyd_child(policy_cfg), node, "egress") + fprintf(fp, " \n", lyd_get_value(node)); + + LYX_LIST_FOR_EACH(lyd_child(policy_cfg), node, "service") + fprintf(fp, " \n", lyd_get_value(node)); + + if (masquerade) + fprintf(fp, " \n"); + + fprintf(fp, "\n"); + + return close_file(fp); +} + +static int generate_firewalld_conf(struct lyd_node *tree) +{ + const char *default_zone, *logging; + FILE *fp; + + fp = fopen(FIREWALLD_CONF, "w"); + if (!fp) { + ERRNO("Failed creating %s", FIREWALLD_CONF); + return SR_ERR_SYS; + } + + default_zone = lydx_get_cattr(tree, "default"); + logging = lydx_get_cattr(tree, "logging"); + + fprintf(fp, "DefaultZone=%s\n", default_zone ? default_zone : "public"); + fprintf(fp, "MinimalMark=100\n"); + fprintf(fp, "CleanupOnExit=yes\n"); + fprintf(fp, "Lockdown=no\n"); + fprintf(fp, "IPv6_rpfilter=yes\n"); + fprintf(fp, "IndividualCalls=no\n"); + fprintf(fp, "LogDenied=%s\n", logging ? logging : "off"); + fprintf(fp, "AutomaticHelpers=system\n"); + fprintf(fp, "FirewallBackend=nftables\n"); + fprintf(fp, "FlushAllOnReload=yes\n"); + fprintf(fp, "RFC3964_IPv4=yes\n"); + fclose(fp); + + return SR_ERR_OK; +} + +static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module, + const char *xpath, sr_event_t event, unsigned request_id, void *_confd) +{ + struct lyd_node *diff, *tree, *list, *node, *global; + struct lyd_node *clist, *cnode; + bool reload_needed = false; + sr_error_t err = SR_ERR_OK; + sr_data_t *cfg; + + switch (event) { + case SR_EV_CHANGE: + /* Validation phase - just return OK for now */ + return SR_ERR_OK; + + case SR_EV_ABORT: + default: + return SR_ERR_OK; + + case SR_EV_DONE: + break; + } + + err = sr_get_data(session, CFG_XPATH "//.", 0, 0, 0, &cfg); + if (err || !cfg) + return SR_ERR_INTERNAL; + + tree = cfg->tree; + global = lydx_get_descendant(tree, "firewall", NULL); + + err = srx_get_diff(session, &diff); + if (err) + goto err_release_data; + + if (!diff) + goto err_release_data; + + if (lydx_is_enabled(global, "enabled")) { + system("initctl -nbq enable firewalld"); + } else { + system("initctl -nbq disable firewalld"); + goto done; + } + + if (lydx_get_descendant(diff, "firewall", "default", NULL) || + lydx_get_descendant(diff, "firewall", "logging", NULL)) { + generate_firewalld_conf(tree); + reload_needed = true; + } + + list = lydx_get_descendant(diff, "firewall", "zones", "zone", NULL); + LYX_LIST_FOR_EACH(list, node, "zone") { + const char *name = lydx_get_cattr(node, "name"); + + if (lydx_get_op(node) == LYDX_OP_DELETE) { + delete_file(FIREWALLD_ZONES_DIR, name); + reload_needed = true; + continue; + } + + clist = lydx_get_descendant(tree, "firewall", "zones", "zone", NULL); + LYX_LIST_FOR_EACH(clist, cnode, "zone") { + if (strcmp(name, lydx_get_cattr(cnode, "name"))) + continue; + + generate_zone(name, cnode); + reload_needed = true; + break; + } + } + + list = lydx_get_descendant(diff, "firewall", "services", "service", NULL); + LYX_LIST_FOR_EACH(list, node, "service") { + const char *name = lydx_get_cattr(node, "name"); + + if (lydx_get_op(node) == LYDX_OP_DELETE) { + delete_file(FIREWALLD_SERVICES_DIR, name); + reload_needed = true; + continue; + } + + clist = lydx_get_descendant(tree, "firewall", "services", "service", NULL); + LYX_LIST_FOR_EACH(clist, cnode, "service") { + if (strcmp(name, lydx_get_cattr(cnode, "name"))) + continue; + + generate_service(name, cnode); + reload_needed = true; + break; + } + } + + list = lydx_get_descendant(diff, "firewall", "policy", NULL); + LYX_LIST_FOR_EACH(list, node, "policy") { + const char *name = lydx_get_cattr(node, "name"); + + if (lydx_get_op(node) == LYDX_OP_DELETE) { + delete_file(FIREWALLD_POLICIES_DIR, name); + reload_needed = true; + continue; + } + + clist = lydx_get_descendant(tree, "firewall", "policy", NULL); + LYX_LIST_FOR_EACH(clist, cnode, "policy") { + if (strcmp(name, lydx_get_cattr(cnode, "name"))) + continue; + + generate_policy(name, cnode); + reload_needed = true; + break; + } + } + + if (reload_needed) + system("initctl -nbq touch firewalld"); + +done: + lyd_free_tree(diff); +err_release_data: + sr_release_data(cfg); + + return err; +} + +int infix_firewall_init(struct confd *confd) +{ + int rc; + + mkdir(FIREWALLD_DIR, 0755); + mkdir(FIREWALLD_ZONES_DIR, 0755); + mkdir(FIREWALLD_SERVICES_DIR, 0755); + mkdir(FIREWALLD_POLICIES_DIR, 0755); + + REGISTER_CHANGE(confd->session, MODULE, CFG_XPATH, 0, change, confd, &confd->sub); + + return SR_ERR_OK; +fail: + ERROR("init failed: %s", sr_strerror(rc)); + return rc; +} diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc index f260c53a6..fb6106f6e 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -28,6 +28,7 @@ MODULES=( "infix-dhcp-common@2025-01-29.yang" "infix-dhcp-client@2025-01-29.yang" "infix-dhcp-server@2025-01-29.yang" + "infix-firewall@2025-04-26.yang" "infix-meta@2024-10-18.yang" "infix-system@2025-01-25.yang" "infix-services@2024-12-03.yang" diff --git a/src/confd/yang/confd/infix-firewall.yang b/src/confd/yang/confd/infix-firewall.yang new file mode 100644 index 000000000..851b18526 --- /dev/null +++ b/src/confd/yang/confd/infix-firewall.yang @@ -0,0 +1,431 @@ +module infix-firewall { + yang-version 1.1; + namespace "urn:infix:firewall:ns:yang:1.0"; + prefix ifw; + + import ietf-inet-types { + prefix inet; + reference "RFC 6991: Common YANG Data Types"; + } + + import ietf-interfaces { + prefix if; + reference "RFC 8343: A YANG Data Model for Interface Management"; + } + + organization "KernelKit"; + contact "kernelkit@googlegroups.com"; + description "Zone-based firewall inspired by firewalld concepts."; + + revision 2025-04-26 { + description "Initial revision."; + reference "internal"; + } + + /* + * Type definitions + */ + + typedef rule-action { + description "Actions that can be performed on packets."; + + type enumeration { + enum accept { + description "Accept the packet."; + } + enum reject { + description "Reject the packet and send an ICMP error."; + } + enum drop { + description "Drop the packet silently."; + } + enum mark { + description "Mark the packet."; + } + } + } + + typedef rule-family { + description "Address family for direct nftables use."; + + type enumeration { + enum ipv4 { + description "IPv4."; + } + enum ipv6 { + description "IPv6."; + } + enum bridge { + description "Ethernet bridge."; + } + } + } + + typedef zone-policy { + description "Default policy for a zone."; + + type enumeration { + enum accept { + description "Accept all connections by default."; + } + enum reject { + description "Reject all connections by default."; + } + enum drop { + description "Drop all connections by default."; + } + } + } + + typedef zone-ref { + description "Reference to a named zone or symbolic value: 'host' or 'any'."; + type union { + type string { + pattern 'host|any'; + } + type leafref { + path "../../zones/zone/name"; + } + } + } + + typedef policy-action { + type enumeration { + enum continue { + description "Non-terminal policy. Matching traffic is accepted or allowed to proceed, and other policies continue to be evaluated."; + } + enum accept { + description "Accept matching traffic and stop evaluating further policies."; + } + enum reject { + description "Reject matching traffic (e.g., send ICMP unreachable) and stop evaluating further policies."; + } + enum drop { + description "Silently drop matching traffic and stop evaluating further policies."; + } + } + description "Action for traffic that does not match any specific service or port entry."; + } + + typedef protocol-type { + description "Network protocols supported for services and port definitions."; + + type enumeration { + enum tcp { + description "TCP protocol."; + } + enum udp { + description "UDP protocol."; + } + enum sctp { + description "SCTP protocol."; + } + enum dccp { + description "DCCP protocol."; + } + } + } + + // firewall-cmd --get-icmptypes + typedef icmp-type { + description "Available ICMP/ICMPv6 types for icmp-blocks."; + + type enumeration { + enum echo-reply { + description "Echo reply."; + } + enum destination-unreachable { + description "Destination unreachable."; + } + enum source-quench { + description "Source quench."; + } + enum redirect { + description "Redirect."; + } + enum echo-request { + description "Echo request."; + } + enum router-advertisement { + description "Router advertisement."; + } + enum router-solicitation { + description "Router solicitation."; + } + enum time-exceeded { + description "Time exceeded."; + } + enum parameter-problem { + description "Parameter problem."; + } + enum timestamp-request { + description "Timestamp request."; + } + enum timestamp-reply { + description "Timestamp reply."; + } + enum address-mask-request { + description "Address mask request."; + } + enum address-mask-reply { + description "Address mask reply."; + } + enum packet-too-big { + description "Packet too big."; + } + enum neighbor-solicitation { + description "Neighbor solicitation."; + } + enum neighbor-advertisement { + description "Neighbor advertisement."; + } + } + } + + /* + * Main container and configuration + */ + + container firewall { + description "Zone-based firewall configuration."; + + leaf enabled { + description "Enable or disable the firewall."; + type boolean; + default true; + } + + leaf default { + description "Default zone for interfaces. + + Any interface not explicitly associated with a zone is placed in this zone."; + type leafref { + path "../zones/zone/name"; + } + } + + leaf logging { + description "Log level for denied (rejected/dropped) packets."; + type enumeration { + enum all { + description "Log all denied packets."; + } + enum unicast { + description "Log unicast denied packets."; + } + enum broadcast { + description "Log broadcast denied packets."; + } + enum multicast { + description "Log multicast denied packets."; + } + enum off { + description "Do not log denied packets."; + } + } + default off; + } + + container zones { + description "Available firewall zones."; + + list zone { + description "A zone defines a level of trust for network connections."; + key "name"; + + leaf name { + description "Name of the zone."; + type string { + length "1..17"; + pattern '[a-zA-Z0-9\-_]+'; + } + } + + leaf description { + description "Free-form description of the zone."; + type string; + } + + leaf policy { + description "Default action for traffic matching no explicit rule. + + Note, see also the 'forwarding' setting for this zone."; + type zone-policy; + default reject; + } + + leaf-list interfaces { + description "Interfaces assigned to this zone."; + type if:interface-ref; + } + + leaf-list sources { + description "Source networks assigned to this zone."; + type inet:ip-prefix; + } + + leaf forwarding { + description "Allow forwarding between interfaces/sources in the same zone. + + Note, this setting applies regardless of the zone policy!"; + type boolean; + default false; + } + + leaf masquerade { + description "Enable masquerading (SNAT) for traffic egressing this zone."; + type boolean; + default false; + } + + list forward { + description "Forward traffic another port and/or host (DNAT)."; + key "port proto"; + + leaf port { + description "Local port to forward from."; + type inet:port-number; + } + + leaf proto { + description "Network protocol."; + type protocol-type; + } + + container to { + description "Forward matching traffic to given address and port."; + leaf addr { + description "IP address to forward to."; + type inet:ip-address; + } + + leaf port { + description "Port to forward to."; + type inet:port-number; + } + } + } + + leaf-list services { + description "Services allowed to ingress this zone."; + type leafref { + path "../../../services/service/name"; + } + } + + list icmp-blocks { + description "ICMP and ICMPv6 types to block."; + key "icmp-type"; + + leaf icmp-type { + description "ICMP type."; + type icmp-type; + } + } + } + } + + list policy { + description "Rules for filtering traffic forwarded between zones."; + key "name"; + + leaf name { + description "Unique identifier (filename) for this policy, e.g., LAN-to-WAN."; + type string { + length "1..17"; + pattern '[a-zA-Z0-9\-_]+'; + } + } + + leaf description { + description "Free-form description of this policy's purpose and scope."; + type string; + } + + leaf-list ingress { + type ifw:zone-ref; + description "List of zones traffic is entering. Use symbolic 'host' or 'any' as needed."; + } + + leaf-list egress { + description "List of zones traffic is exiting from. Use symbolic 'host' or 'any' as needed."; + type ifw:zone-ref; + } + + leaf masquerade { + description "Enable masquerading (SNAT) for traffic matching this policy."; + type boolean; + } + + leaf policy { + description "Policy for non-matching traffic. 'continue' means policy is non-terminal."; + type policy-action; + default "continue"; + } + + leaf-list service { + description "Allowed services, all other traffic follows the policy default action."; + type leafref { + path "../../services/service/name"; + } + } + } + + container services { + description "Predefined network services."; + + list service { + description "Manage services, human-friendly names of port+protocol pairs. + + A service is a collection of port and protocol pairs. Used by the firewall + instead of hard-coding raw port numbers everywhere."; + key "name"; + + leaf name { + description "Name of the service."; + type string { + length "1..17"; + pattern '[a-zA-Z0-9\-_]+'; + } + } + + leaf description { + description "Free-form description of the service."; + type string; + } + + list port { + description "Port, or range of ports, and protocol to match."; + key "lower proto"; + + leaf lower { + type inet:port-number; + description "Lower port in range."; + } + + leaf upper { + type inet:port-number; + must "../lower <= ."; + description "Upper port in range."; + } + + leaf proto { + description "Layer 4 protocol."; + type protocol-type; + } + + // Rare, but one example is upnp: 1900/udp + leaf match-source { + description "Match on source port(s) instead of default: destination."; + type boolean; + } + } + + leaf destination { + type inet:ip-address; + description "Destination IP address/group to match this service to."; + } + } + } + } +} diff --git a/src/confd/yang/confd/infix-firewall@2025-04-26.yang b/src/confd/yang/confd/infix-firewall@2025-04-26.yang new file mode 120000 index 000000000..6e6fada47 --- /dev/null +++ b/src/confd/yang/confd/infix-firewall@2025-04-26.yang @@ -0,0 +1 @@ +infix-firewall.yang \ No newline at end of file From 198428b685e24b02663487bd7dc670a870551496 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 8 Aug 2025 15:17:55 +0200 Subject: [PATCH 05/55] WIP: firewall infer and well-known-services.yang Signed-off-by: Joachim Wiberg --- src/confd/src/infix-firewall.c | 62 ++++++++ src/confd/yang/confd.inc | 1 + .../yang/confd/infix-firewall-services.yang | 138 ++++++++++++++++++ .../infix-firewall-services@2025-04-26.yang | 1 + src/confd/yang/confd/infix-firewall.yang | 19 ++- 5 files changed, 217 insertions(+), 4 deletions(-) create mode 100644 src/confd/yang/confd/infix-firewall-services.yang create mode 120000 src/confd/yang/confd/infix-firewall-services@2025-04-26.yang diff --git a/src/confd/src/infix-firewall.c b/src/confd/src/infix-firewall.c index 33a8e23be..e7aa7b5cf 100644 --- a/src/confd/src/infix-firewall.c +++ b/src/confd/src/infix-firewall.c @@ -258,6 +258,56 @@ static int generate_firewalld_conf(struct lyd_node *tree) return SR_ERR_OK; } +static int create_default_zone(sr_session_ctx_t *session, const char *name, + const char *policy, const char *desc, + const char *services[]) +{ + char xpath[256]; + int rc; + + snprintf(xpath, sizeof(xpath), CFG_XPATH "/zones/zone[name='%s']/name", name); + rc = sr_set_item_str(session, xpath, name, NULL, 0); + if (rc) return rc; + + snprintf(xpath, sizeof(xpath), CFG_XPATH "/zones/zone[name='%s']/description", name); + rc = sr_set_item_str(session, xpath, desc, NULL, 0); + if (rc) return rc; + + snprintf(xpath, sizeof(xpath), CFG_XPATH "/zones/zone[name='%s']/policy", name); + rc = sr_set_item_str(session, xpath, policy, NULL, 0); + if (rc) return rc; + + for (int i = 0; services && services[i]; i++) { + snprintf(xpath, sizeof(xpath), CFG_XPATH "/zones/zone[name='%s']/services[.='%s']", name, services[i]); + rc = sr_set_item_str(session, xpath, services[i], NULL, 0); + if (rc) return rc; + } + + return SR_ERR_OK; +} + +static int infer_default_zones(sr_session_ctx_t *session) +{ + int rc; + + const char *internal_services[] = {"ssh", "dns", "http", "https", NULL}; + rc = create_default_zone(session, "internal", "accept", "Internal trusted network", internal_services); + if (rc) return rc; + + rc = create_default_zone(session, "external", "drop", "External untrusted network", NULL); + if (rc) return rc; + + const char *dmz_services[] = {"http", "https", NULL}; + rc = create_default_zone(session, "dmz", "reject", "Demilitarized zone", dmz_services); + if (rc) return rc; + + const char *public_services[] = {"ssh", NULL}; + rc = create_default_zone(session, "public", "reject", "Public access zone", public_services); + if (rc) return rc; + + return SR_ERR_OK; +} + static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module, const char *xpath, sr_event_t event, unsigned request_id, void *_confd) { @@ -295,7 +345,17 @@ static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module goto err_release_data; if (lydx_is_enabled(global, "enabled")) { + sr_data_t *zones_data; + system("initctl -nbq enable firewalld"); + + if (!sr_get_data(session, CFG_XPATH "/zones", 0, 0, 0, &zones_data) && + (!zones_data || !zones_data->tree)) { + infer_default_zones(session); + sr_apply_changes(session, 0); + } + if (zones_data) + sr_release_data(zones_data); } else { system("initctl -nbq disable firewalld"); goto done; @@ -390,6 +450,8 @@ int infix_firewall_init(struct confd *confd) mkdir(FIREWALLD_SERVICES_DIR, 0755); mkdir(FIREWALLD_POLICIES_DIR, 0755); + sr_apply_changes(confd->session, 0); + REGISTER_CHANGE(confd->session, MODULE, CFG_XPATH, 0, change, confd, &confd->sub); return SR_ERR_OK; diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc index fb6106f6e..ff1b0aab9 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -29,6 +29,7 @@ MODULES=( "infix-dhcp-client@2025-01-29.yang" "infix-dhcp-server@2025-01-29.yang" "infix-firewall@2025-04-26.yang" + "infix-firewall-services@2025-04-26.yang" "infix-meta@2024-10-18.yang" "infix-system@2025-01-25.yang" "infix-services@2024-12-03.yang" diff --git a/src/confd/yang/confd/infix-firewall-services.yang b/src/confd/yang/confd/infix-firewall-services.yang new file mode 100644 index 000000000..cd5384667 --- /dev/null +++ b/src/confd/yang/confd/infix-firewall-services.yang @@ -0,0 +1,138 @@ +module infix-firewall-services { + yang-version 1.1; + namespace "urn:infix:firewall:services:ns:yang:1.0"; + prefix ifw-svc; + + organization "KernelKit"; + contact "kernelkit@googlegroups.com"; + description "Well-defined network services for Infix firewall. + + This module defines an enumeration of well-known network services + following the pattern established by iana-timezones.yang, where each + service is represented as an enum value with a descriptive text + explaining what the service does and the ports/protocols it uses."; + + revision 2025-04-26 { + description "Refactored to use enum-based approach similar to iana-timezones.yang. + Each well-known service is now defined as an enum value with + human-friendly descriptions covering purpose and technical details."; + reference "internal"; + } + + typedef well-known-service { + description "Well-known network services, with standard port assignments from IANA."; + type enumeration { + enum "bgp" { + description "tcp/179 — Border Gateway Protocol for internet routing"; + } + enum "dhcp" { + description "udp/67-68 — Dynamic Host Configuration Protocol for network configuration"; + } + enum "dns" { + description "tcp+udp/53 — Domain Name System for name resolution"; + } + enum "ftp" { + description "tcp/20-21 — File Transfer Protocol for file transfers"; + } + enum "http" { + description "tcp/80 — Hypertext Transfer Protocol for web traffic"; + } + enum "https" { + description "tcp/443 — Secure Hypertext Transfer Protocol for encrypted web traffic"; + } + enum "imap" { + description "tcp/143 — Internet Message Access Protocol for email access"; + } + enum "imaps" { + description "tcp/993 — Secure Internet Message Access Protocol for encrypted email access"; + } + enum "kerberos" { + description "tcp+udp/88 — Network authentication protocol"; + } + enum "ldap" { + description "tcp/389 — Lightweight Directory Access Protocol for directory services"; + } + enum "mdns" { + description "udp/5353 — Multicast DNS for local network service discovery"; + } + enum "mongodb" { + description "tcp/27017 — Document-oriented NoSQL database"; + } + enum "mqtt" { + description "tcp/1883 — Message Queuing Telemetry Transport for IoT"; + } + enum "mssql" { + description "tcp/1433 — Microsoft SQL Server database"; + } + enum "mysql" { + description "tcp/3306 — MySQL database server connections"; + } + enum "netbios-ns" { + description "udp/137 — NetBIOS Name Service for Windows networking"; + } + enum "nfs" { + description "tcp+udp/2049 — Network File System for distributed file sharing"; + } + enum "ntp" { + description "udp/123 — Network Time Protocol for time synchronization"; + } + enum "openvpn" { + description "udp/1194 — OpenVPN secure tunnel for VPN connections"; + } + enum "pop3" { + description "tcp/110 — Post Office Protocol version 3 for email retrieval"; + } + enum "pop3s" { + description "tcp/995 — Secure Post Office Protocol version 3 for encrypted email retrieval"; + } + enum "postgresql" { + description "tcp/5432 — PostgreSQL database server connections"; + } + enum "radius" { + description "tcp+udp/1812-1813 — Remote Authentication Dial-in User Service"; + } + enum "rdp" { + description "tcp/3389 — Remote Desktop Protocol for Windows remote access"; + } + enum "samba" { + description "tcp/445 — Windows file and printer sharing"; + } + enum "sip" { + description "tcp+udp/5060 — Session Initiation Protocol for VoIP communications"; + } + enum "sips" { + description "tcp+udp/5061 — Secure Session Initiation Protocol for encrypted VoIP"; + } + enum "smtp" { + description "tcp/25 — Simple Mail Transfer Protocol for email transmission"; + } + enum "snmp" { + description "udp/161 — Simple Network Management Protocol for network monitoring"; + } + enum "snmptrap" { + description "udp/162 — Simple Network Management Protocol trap notifications"; + } + enum "ssh" { + description "tcp/22 — Secure Shell for remote login and command execution"; + } + enum "ssdp" { + description "udp/1900 — Simple Service Discovery Protocol for UPnP device discovery"; + } + enum "telnet" { + description "tcp/23 — Telnet protocol for remote terminal access"; + } + enum "tftp" { + description "udp/69 — Trivial File Transfer Protocol for simple file transfers"; + } + enum "vnc-server" { + description "tcp/5900-5906 — Virtual Network Computing server for remote desktop access"; + } + enum "wireguard" { + description "udp/51820 — Modern VPN tunnel for secure networking"; + } + enum "xdmcp" { + description "tcp+udp/177 — X Display Manager Control Protocol for remote X11 sessions"; + } + } + } +} diff --git a/src/confd/yang/confd/infix-firewall-services@2025-04-26.yang b/src/confd/yang/confd/infix-firewall-services@2025-04-26.yang new file mode 120000 index 000000000..12d91ca31 --- /dev/null +++ b/src/confd/yang/confd/infix-firewall-services@2025-04-26.yang @@ -0,0 +1 @@ +infix-firewall-services.yang \ No newline at end of file diff --git a/src/confd/yang/confd/infix-firewall.yang b/src/confd/yang/confd/infix-firewall.yang index 851b18526..7e1605e68 100644 --- a/src/confd/yang/confd/infix-firewall.yang +++ b/src/confd/yang/confd/infix-firewall.yang @@ -13,6 +13,11 @@ module infix-firewall { reference "RFC 8343: A YANG Data Model for Interface Management"; } + import infix-firewall-services { + prefix ifw-svc; + reference "infix-firewall-services.yang"; + } + organization "KernelKit"; contact "kernelkit@googlegroups.com"; description "Zone-based firewall inspired by firewalld concepts."; @@ -308,8 +313,11 @@ module infix-firewall { leaf-list services { description "Services allowed to ingress this zone."; - type leafref { - path "../../../services/service/name"; + type union { + type leafref { + path "../../../services/service/name"; + } + type ifw-svc:well-known-service; } } @@ -365,8 +373,11 @@ module infix-firewall { leaf-list service { description "Allowed services, all other traffic follows the policy default action."; - type leafref { - path "../../services/service/name"; + type union { + type leafref { + path "../../services/service/name"; + } + type ifw-svc:well-known-service; } } } From 095e028694ae23773c410dd4b00faed003ad2cdd Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Wed, 13 Aug 2025 12:28:33 +0200 Subject: [PATCH 06/55] confd: drop advanced firewall features for first drop Signed-off-by: Joachim Wiberg --- src/confd/src/infix-firewall.c | 13 ++++++++++++- src/confd/yang/confd/infix-firewall.yang | 23 ++++++++++++++--------- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/confd/src/infix-firewall.c b/src/confd/src/infix-firewall.c index e7aa7b5cf..d963c9d46 100644 --- a/src/confd/src/infix-firewall.c +++ b/src/confd/src/infix-firewall.c @@ -139,15 +139,19 @@ static int generate_zone(const char *name, struct lyd_node *cfg) } } +#if 0 /* ADVANCED FEATURE: icmp-blocks removed from YANG model */ LYX_LIST_FOR_EACH(lyd_child(cfg), node, "icmp-blocks") { const char *icmp_type = lydx_get_cattr(node, "icmp-type"); fprintf(fp, " \n", icmp_type); } +#endif if (lydx_is_enabled(cfg, "forwarding")) fprintf(fp, " \n"); +#if 0 /* REMOVED: Zone-level masquerade - handled by policy rules instead */ if (lydx_is_enabled(cfg, "masquerade")) fprintf(fp, " \n"); +#endif fprintf(fp, "\n"); @@ -156,7 +160,10 @@ static int generate_zone(const char *name, struct lyd_node *cfg) static int generate_service(const char *name, struct lyd_node *service_cfg) { - const char *desc, *destination; + const char *desc; +#if 0 /* ADVANCED FEATURE: destination variable for service destinations */ + const char *destination; +#endif struct lyd_node *node; FILE *fp; @@ -165,15 +172,19 @@ static int generate_service(const char *name, struct lyd_node *service_cfg) return SR_ERR_SYS; desc = lydx_get_cattr(service_cfg, "description"); +#if 0 /* ADVANCED FEATURE: service destinations removed from YANG model */ destination = lydx_get_cattr(service_cfg, "destination"); +#endif fprintf(fp, "\n"); if (desc) fprintf(fp, " %s\n", desc); +#if 0 /* ADVANCED FEATURE: service destinations removed from YANG model */ if (destination) fprintf(fp, " \n", destination); +#endif LYX_LIST_FOR_EACH(lyd_child(service_cfg), node, "port") { const char *lower = lydx_get_cattr(node, "lower"); diff --git a/src/confd/yang/confd/infix-firewall.yang b/src/confd/yang/confd/infix-firewall.yang index 7e1605e68..43cac6abb 100644 --- a/src/confd/yang/confd/infix-firewall.yang +++ b/src/confd/yang/confd/infix-firewall.yang @@ -96,9 +96,11 @@ module infix-firewall { typedef policy-action { type enumeration { + /* ADVANCED FEATURE: Commented out - requires policy ordering/priority system enum continue { description "Non-terminal policy. Matching traffic is accepted or allowed to proceed, and other policies continue to be evaluated."; } + */ enum accept { description "Accept matching traffic and stop evaluating further policies."; } @@ -131,7 +133,8 @@ module infix-firewall { } } - // firewall-cmd --get-icmptypes + /* firewall-cmd --get-icmptypes */ + /* ADVANCED FEATURE: Commented out for simplicity - users rarely need granular ICMP control typedef icmp-type { description "Available ICMP/ICMPv6 types for icmp-blocks."; @@ -186,6 +189,7 @@ module infix-firewall { } } } + */ /* * Main container and configuration @@ -277,11 +281,7 @@ module infix-firewall { default false; } - leaf masquerade { - description "Enable masquerading (SNAT) for traffic egressing this zone."; - type boolean; - default false; - } + /* REMOVED: Zone-level masquerade is handled by policy rules instead */ list forward { description "Forward traffic another port and/or host (DNAT)."; @@ -321,6 +321,7 @@ module infix-firewall { } } + /* ADVANCED FEATURE: Commented out for simplicity - users rarely need granular ICMP control list icmp-blocks { description "ICMP and ICMPv6 types to block."; key "icmp-type"; @@ -330,6 +331,7 @@ module infix-firewall { type icmp-type; } } + */ } } @@ -366,9 +368,9 @@ module infix-firewall { } leaf policy { - description "Policy for non-matching traffic. 'continue' means policy is non-terminal."; + description "Policy for non-matching traffic. All policies are terminal (accept/reject/drop)."; type policy-action; - default "continue"; + default "reject"; } leaf-list service { @@ -425,17 +427,20 @@ module infix-firewall { type protocol-type; } - // Rare, but one example is upnp: 1900/udp + /* ADVANCED FEATURE: Commented out for simplicity - rarely needed leaf match-source { description "Match on source port(s) instead of default: destination."; type boolean; } + */ } + /* ADVANCED FEATURE: Commented out for simplicity - service-specific destinations are complex leaf destination { type inet:ip-address; description "Destination IP address/group to match this service to."; } + */ } } } From 90353412c134b97d26c7c8d889754b144d89afec Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 14 Aug 2025 07:27:04 +0200 Subject: [PATCH 07/55] libsrx: new helper, srx_set_bool() Signed-off-by: Joachim Wiberg --- src/libsrx/src/srx_val.c | 47 +++++++++++++++++++++++++++++++++------- src/libsrx/src/srx_val.h | 2 ++ 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/libsrx/src/srx_val.c b/src/libsrx/src/srx_val.c index 961e72fa2..ac8204588 100644 --- a/src/libsrx/src/srx_val.c +++ b/src/libsrx/src/srx_val.c @@ -149,28 +149,59 @@ bool srx_isset(sr_session_ctx_t *session, const char *fmt, ...) return isset; } -int srx_set_item(sr_session_ctx_t *session, const sr_val_t *val, sr_edit_options_t opts, - const char *fmt, ...) +static int set_vaitem(sr_session_ctx_t *session, const sr_val_t *val, sr_edit_options_t opts, + const char *fmt, va_list ap) { + va_list apdup; char *xpath; - va_list ap; size_t len; - va_start(ap, fmt); - len = vsnprintf(NULL, 0, fmt, ap) + 1; - va_end(ap); + va_copy(apdup, ap); + len = vsnprintf(NULL, 0, fmt, apdup) + 1; + va_end(apdup); xpath = alloca(len); if (!xpath) return -1; + va_copy(apdup, ap); + vsnprintf(xpath, len, fmt, apdup); + va_end(apdup); + + return sr_set_item(session, xpath, val, opts); +} + +int srx_set_item(sr_session_ctx_t *session, const sr_val_t *val, sr_edit_options_t opts, + const char *fmt, ...) +{ + va_list ap; + int rc; + va_start(ap, fmt); - vsnprintf(xpath, len, fmt, ap); + rc = set_vaitem(session, val, opts, fmt, ap); va_end(ap); - return sr_set_item(session, xpath, val, opts); + return rc; } +int srx_set_bool(sr_session_ctx_t *session, bool ena, sr_edit_options_t opts, + const char *fmt, ...) +{ + sr_val_t val = { + .type = SR_BOOL_T, + .data.bool_val = ena + }; + va_list ap; + int rc; + + va_start(ap, fmt); + rc = set_vaitem(session, &val, opts, fmt, ap); + va_end(ap); + + return rc; +} + + int srx_set_str(sr_session_ctx_t *session, const char *str, sr_edit_options_t opts, const char *fmt, ...) { diff --git a/src/libsrx/src/srx_val.h b/src/libsrx/src/srx_val.h index e95a3f79a..1b2d1adaa 100644 --- a/src/libsrx/src/srx_val.h +++ b/src/libsrx/src/srx_val.h @@ -20,6 +20,8 @@ int srx_set_item(sr_session_ctx_t *, const sr_val_t *, sr_edit_options_t, const __attribute__ ((format (printf, 4, 5))); int srx_set_str(sr_session_ctx_t *, const char *, sr_edit_options_t, const char *fmt, ...) __attribute__ ((format (printf, 4, 5))); +int srx_set_bool(sr_session_ctx_t *session, bool ena, sr_edit_options_t opts, const char *fmt, ...) + __attribute__ ((format (printf, 4, 5))); char *srx_get_str (sr_session_ctx_t *session, const char *fmt, ...) __attribute__ ((format (printf, 2, 3))); From 464718852e69678e751035ebe25dd79ef31f3981 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 14 Aug 2025 07:28:49 +0200 Subject: [PATCH 08/55] confd: minor, replace hard-coded string with define Signed-off-by: Joachim Wiberg --- src/confd/src/infix-dhcp-server.c | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/confd/src/infix-dhcp-server.c b/src/confd/src/infix-dhcp-server.c index a942ddf11..a0bce16f9 100644 --- a/src/confd/src/infix-dhcp-server.c +++ b/src/confd/src/infix-dhcp-server.c @@ -398,7 +398,7 @@ static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module static int cand(sr_session_ctx_t *session, uint32_t sub_id, const char *module, const char *path, sr_event_t event, unsigned request_id, void *priv) { - const char *fmt = "/infix-dhcp-server:dhcp-server/option[id='%s']/address"; + const char *fmt = CFG_XPATH "/option[id='%s']/address"; sr_val_t inferred = { .type = SR_STRING_T }; const char *opt[] = { "router", @@ -409,7 +409,7 @@ static int cand(sr_session_ctx_t *session, uint32_t sub_id, const char *module, if (event != SR_EV_UPDATE && event != SR_EV_CHANGE) return 0; - if (srx_nitems(session, &cnt, "/infix-dhcp-server:dhcp-server/option") || cnt) + if (srx_nitems(session, &cnt, CFG_XPATH "/option") || cnt) return 0; for (i = 0; i < NELEMS(opt); i++) { @@ -422,7 +422,7 @@ static int cand(sr_session_ctx_t *session, uint32_t sub_id, const char *module, static int clear_stats(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, - unsigned request_id, sr_val_t **output, size_t *output_cnt, void *priv) + uint32_t request_id, sr_val_t **output, size_t *output_cnt, void *priv) { if (systemf("dbus-send --system --dest=uk.org.thekelleys.dnsmasq " "/uk/org/thekelleys/dnsmasq uk.org.thekelleys.dnsmasq.ClearMetrics")) @@ -436,7 +436,7 @@ int infix_dhcp_server_init(struct confd *confd) int rc; REGISTER_CHANGE(confd->session, MODULE, CFG_XPATH, 0, change, confd, &confd->sub); - REGISTER_CHANGE(confd->cand, MODULE, CFG_XPATH"//.", SR_SUBSCR_UPDATE, cand, confd, &confd->sub); + REGISTER_CHANGE(confd->cand, MODULE, CFG_XPATH "//.", SR_SUBSCR_UPDATE, cand, confd, &confd->sub); REGISTER_RPC(confd->session, CFG_XPATH "/statistics/clear", clear_stats, NULL, &confd->sub); return SR_ERR_OK; From 5e71367bcb1d6d4083a1f1fd8c297003f6040203 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 14 Aug 2025 07:30:16 +0200 Subject: [PATCH 09/55] confd: further simplify firewall Signed-off-by: Joachim Wiberg --- package/confd/confd.mk | 12 +- src/confd/src/infix-firewall.c | 163 +++++++------ src/confd/yang/confd/infix-firewall.yang | 287 ++++++++++------------- 3 files changed, 221 insertions(+), 241 deletions(-) diff --git a/package/confd/confd.mk b/package/confd/confd.mk index 2f5e10c41..6db96c7a6 100644 --- a/package/confd/confd.mk +++ b/package/confd/confd.mk @@ -100,12 +100,22 @@ define CONFD_EMPTY_SYSREPO rm -rf $(PER_PACKAGE_DIR)/host-sysrepo/target/etc/sysrepo/* $(PER_PACKAGE_DIR)/confd/target/etc/sysrepo/*; \ fi endef + +# The three zones that are *not* deleted from the default install +# are required by firewalld (core/fw.py), in particular block.xml define CONFD_CLEANUP rm -f /dev/shm/$(CONFD_SYSREPO_SHM_PREFIX)* rm -rf $(TARGET_DIR)/etc/firewall* rm -f $(TARGET_DIR)/usr/bin/firewall-applet rm -rf $(TARGET_DIR)/usr/share/firewalld - mkdir -p $(TARGET_DIR)/etc/firewalld + find $(TARGET_DIR)/usr/lib/firewalld/zones -type f \ + ! -name block.xml \ + ! -name drop.xml \ + ! -name trusted.xml \ + -delete + mkdir -p $(TARGET_DIR)/etc/firewalld/zones + mkdir -p $(TARGET_DIR)/etc/firewalld/policies + mkdir -p $(TARGET_DIR)/etc/firewalld/services touch $(TARGET_DIR)/etc/firewalld/firewalld.conf endef CONFD_PRE_BUILD_HOOKS += CONFD_EMPTY_SYSREPO diff --git a/src/confd/src/infix-firewall.c b/src/confd/src/infix-firewall.c index d963c9d46..31a8a7004 100644 --- a/src/confd/src/infix-firewall.c +++ b/src/confd/src/infix-firewall.c @@ -15,7 +15,7 @@ #include "core.h" #define MODULE "infix-firewall" -#define CFG_XPATH "/infix-firewall:firewall" +#define XPATH "/infix-firewall:firewall" #define FIREWALLD_DIR "/etc/firewalld" #define FIREWALLD_CONF FIREWALLD_DIR "/firewalld.conf" @@ -106,7 +106,7 @@ static int generate_zone(const char *name, struct lyd_node *cfg) desc = lydx_get_cattr(cfg, "description"); fprintf(fp, "\n", zone_policy_to_target(policy)); - fprintf(fp, " %s", name); + fprintf(fp, " %s\n", name); if (desc) fprintf(fp, " %s\n", desc); @@ -119,7 +119,7 @@ static int generate_zone(const char *name, struct lyd_node *cfg) LYX_LIST_FOR_EACH(lyd_child(cfg), node, "services") fprintf(fp, " \n", lyd_get_value(node)); - LYX_LIST_FOR_EACH(lyd_child(cfg), node, "forward") { + LYX_LIST_FOR_EACH(lyd_child(cfg), node, "port-forward") { const char *port = lydx_get_cattr(node, "port"); const char *proto = lydx_get_cattr(node, "proto"); struct lyd_node *to = lydx_get_child(node, "to"); @@ -241,7 +241,6 @@ static int generate_policy(const char *name, struct lyd_node *policy_cfg) static int generate_firewalld_conf(struct lyd_node *tree) { - const char *default_zone, *logging; FILE *fp; fp = fopen(FIREWALLD_CONF, "w"); @@ -250,16 +249,13 @@ static int generate_firewalld_conf(struct lyd_node *tree) return SR_ERR_SYS; } - default_zone = lydx_get_cattr(tree, "default"); - logging = lydx_get_cattr(tree, "logging"); - - fprintf(fp, "DefaultZone=%s\n", default_zone ? default_zone : "public"); + fprintf(fp, "DefaultZone=%s\n", lydx_get_cattr(tree, "default")); fprintf(fp, "MinimalMark=100\n"); fprintf(fp, "CleanupOnExit=yes\n"); fprintf(fp, "Lockdown=no\n"); fprintf(fp, "IPv6_rpfilter=yes\n"); fprintf(fp, "IndividualCalls=no\n"); - fprintf(fp, "LogDenied=%s\n", logging ? logging : "off"); + fprintf(fp, "LogDenied=%s\n", lydx_get_cattr(tree, "logging") ?: "off"); fprintf(fp, "AutomaticHelpers=system\n"); fprintf(fp, "FirewallBackend=nftables\n"); fprintf(fp, "FlushAllOnReload=yes\n"); @@ -269,52 +265,34 @@ static int generate_firewalld_conf(struct lyd_node *tree) return SR_ERR_OK; } -static int create_default_zone(sr_session_ctx_t *session, const char *name, - const char *policy, const char *desc, - const char *services[]) +static int infer_zone(sr_session_ctx_t *session, const char *name, const char *desc, + const char *policy, bool forwarding, const char *services[]) { - char xpath[256]; int rc; - snprintf(xpath, sizeof(xpath), CFG_XPATH "/zones/zone[name='%s']/name", name); - rc = sr_set_item_str(session, xpath, name, NULL, 0); - if (rc) return rc; - - snprintf(xpath, sizeof(xpath), CFG_XPATH "/zones/zone[name='%s']/description", name); - rc = sr_set_item_str(session, xpath, desc, NULL, 0); - if (rc) return rc; - - snprintf(xpath, sizeof(xpath), CFG_XPATH "/zones/zone[name='%s']/policy", name); - rc = sr_set_item_str(session, xpath, policy, NULL, 0); - if (rc) return rc; - - for (int i = 0; services && services[i]; i++) { - snprintf(xpath, sizeof(xpath), CFG_XPATH "/zones/zone[name='%s']/services[.='%s']", name, services[i]); - rc = sr_set_item_str(session, xpath, services[i], NULL, 0); - if (rc) return rc; - } - - return SR_ERR_OK; -} + ERROR("Inferring zone %s (%s), policy %s forwarding %d", name, desc, policy, forwarding); -static int infer_default_zones(sr_session_ctx_t *session) -{ - int rc; + rc = srx_set_str(session, name, 0, XPATH "/zone[name='%s']/name", name); + if (rc) + return rc; - const char *internal_services[] = {"ssh", "dns", "http", "https", NULL}; - rc = create_default_zone(session, "internal", "accept", "Internal trusted network", internal_services); - if (rc) return rc; + rc = srx_set_str(session, desc, 0, XPATH "/zone[name='%s']/description", name); + if (rc) + return rc; - rc = create_default_zone(session, "external", "drop", "External untrusted network", NULL); - if (rc) return rc; + rc = srx_set_str(session, policy, 0, XPATH "/zone[name='%s']/policy", name); + if (rc) + return rc; - const char *dmz_services[] = {"http", "https", NULL}; - rc = create_default_zone(session, "dmz", "reject", "Demilitarized zone", dmz_services); - if (rc) return rc; + rc = srx_set_bool(session, forwarding, 0, XPATH "/zone[name='%s']/forwarding", name); + if (rc) + return rc; - const char *public_services[] = {"ssh", NULL}; - rc = create_default_zone(session, "public", "reject", "Public access zone", public_services); - if (rc) return rc; + for (int i = 0; services && services[i]; i++) { + rc = srx_set_str(session, services[i], 0, XPATH "/zone[name='%s']/services[.='%s']", name, services[i]); + if (rc) + return rc; + } return SR_ERR_OK; } @@ -341,7 +319,7 @@ static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module break; } - err = sr_get_data(session, CFG_XPATH "//.", 0, 0, 0, &cfg); + err = sr_get_data(session, XPATH "//.", 0, 0, 0, &cfg); if (err || !cfg) return SR_ERR_INTERNAL; @@ -355,22 +333,8 @@ static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module if (!diff) goto err_release_data; - if (lydx_is_enabled(global, "enabled")) { - sr_data_t *zones_data; - - system("initctl -nbq enable firewalld"); - - if (!sr_get_data(session, CFG_XPATH "/zones", 0, 0, 0, &zones_data) && - (!zones_data || !zones_data->tree)) { - infer_default_zones(session); - sr_apply_changes(session, 0); - } - if (zones_data) - sr_release_data(zones_data); - } else { - system("initctl -nbq disable firewalld"); + if (!global) goto done; - } if (lydx_get_descendant(diff, "firewall", "default", NULL) || lydx_get_descendant(diff, "firewall", "logging", NULL)) { @@ -378,7 +342,7 @@ static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module reload_needed = true; } - list = lydx_get_descendant(diff, "firewall", "zones", "zone", NULL); + list = lydx_get_descendant(diff, "firewall", "zone", NULL); LYX_LIST_FOR_EACH(list, node, "zone") { const char *name = lydx_get_cattr(node, "name"); @@ -388,7 +352,7 @@ static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module continue; } - clist = lydx_get_descendant(tree, "firewall", "zones", "zone", NULL); + clist = lydx_get_descendant(tree, "firewall", "zone", NULL); LYX_LIST_FOR_EACH(clist, cnode, "zone") { if (strcmp(name, lydx_get_cattr(cnode, "name"))) continue; @@ -399,7 +363,7 @@ static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module } } - list = lydx_get_descendant(diff, "firewall", "services", "service", NULL); + list = lydx_get_descendant(diff, "firewall", "service", NULL); LYX_LIST_FOR_EACH(list, node, "service") { const char *name = lydx_get_cattr(node, "name"); @@ -409,7 +373,7 @@ static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module continue; } - clist = lydx_get_descendant(tree, "firewall", "services", "service", NULL); + clist = lydx_get_descendant(tree, "firewall", "service", NULL); LYX_LIST_FOR_EACH(clist, cnode, "service") { if (strcmp(name, lydx_get_cattr(cnode, "name"))) continue; @@ -445,6 +409,8 @@ static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module system("initctl -nbq touch firewalld"); done: + systemf("initctl -nbq %s firewalld", global ? "enable" : "disable"); + lyd_free_tree(diff); err_release_data: sr_release_data(cfg); @@ -452,19 +418,64 @@ static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module return err; } +/* + * Set up default zones with sane defaults: + * + * internal: This is the default zone, which trusts all ingressing traffic. + * It is used for internal trusted networks and allows forwarding + * between all interfaces and networks in the zone. + * + * external: Untrusted zone, for WAN interfaces, only ssh and dhcpv6 client + * traffic is allowed to ingress. + * + */ +static int cand(sr_session_ctx_t *session, uint32_t sub_id, const char *module, + const char *path, sr_event_t event, unsigned request_id, void *priv) +{ + const char *ext_svc[] = {"ssh", "dhcpv6-client", NULL}; + const char *int_svc[] = { NULL}; + size_t cnt = 0; + int rc; + + if (event != SR_EV_UPDATE && event != SR_EV_CHANGE) + return 0; + + /* If unset, this is the first time we're called */ + if (srx_get_str(session, XPATH "/default")) + return 0; + + rc = srx_nitems(session, &cnt, XPATH "/zones"); + if (rc || cnt) { + WARN("firewall has zones defined %zu, but no default zone for new interfaces! (rc %d)", + cnt, rc); + return 0; + } + + rc = infer_zone(session, "external", "External untrusted network, only SSH and DHCPv6 client.", + "drop", true, ext_svc); + if (rc) + return rc; + + rc = infer_zone(session, "internal", "Internal trusted network, forwarding between networks.", + "accept", true, int_svc); + if (rc) + return rc; + + /* Set up default zone for new networks */ + rc = srx_set_str(session, "internal", 0, XPATH "/default"); + if (rc) + return rc; + + return SR_ERR_OK; +} + int infix_firewall_init(struct confd *confd) { int rc; - - mkdir(FIREWALLD_DIR, 0755); - mkdir(FIREWALLD_ZONES_DIR, 0755); - mkdir(FIREWALLD_SERVICES_DIR, 0755); - mkdir(FIREWALLD_POLICIES_DIR, 0755); - - sr_apply_changes(confd->session, 0); - REGISTER_CHANGE(confd->session, MODULE, CFG_XPATH, 0, change, confd, &confd->sub); - + REGISTER_CHANGE(confd->session, MODULE, XPATH, 0, change, confd, &confd->sub); + REGISTER_CHANGE(confd->cand, MODULE, XPATH "//.", SR_SUBSCR_UPDATE, cand, confd, &confd->sub); + return SR_ERR_OK; fail: ERROR("init failed: %s", sr_strerror(rc)); diff --git a/src/confd/yang/confd/infix-firewall.yang b/src/confd/yang/confd/infix-firewall.yang index 43cac6abb..8f949b00a 100644 --- a/src/confd/yang/confd/infix-firewall.yang +++ b/src/confd/yang/confd/infix-firewall.yang @@ -31,38 +31,11 @@ module infix-firewall { * Type definitions */ - typedef rule-action { - description "Actions that can be performed on packets."; - - type enumeration { - enum accept { - description "Accept the packet."; - } - enum reject { - description "Reject the packet and send an ICMP error."; - } - enum drop { - description "Drop the packet silently."; - } - enum mark { - description "Mark the packet."; - } - } - } - - typedef rule-family { - description "Address family for direct nftables use."; - - type enumeration { - enum ipv4 { - description "IPv4."; - } - enum ipv6 { - description "IPv6."; - } - enum bridge { - description "Ethernet bridge."; - } + typedef ident { + description "Generic filesystem-safe identifier (filename)."; + type string { + length "2..64"; + pattern '[a-zA-Z0-9\-_]+'; } } @@ -89,7 +62,7 @@ module infix-firewall { pattern 'host|any'; } type leafref { - path "../../zones/zone/name"; + path "../../zone/name"; } } } @@ -197,20 +170,26 @@ module infix-firewall { container firewall { description "Zone-based firewall configuration."; - + presence "Enable the firewall."; +/* leaf enabled { - description "Enable or disable the firewall."; + description "Enable or disable the firewall. + + Note, by disabling the firewall all rules are unloaded from the kernel, making + the system fully open! This can be useful when debugging firewall issues, but + remember to re-enable when done, and maybe remove connections to the Internet + before disabling."; type boolean; - default true; } - +*/ leaf default { description "Default zone for interfaces. Any interface not explicitly associated with a zone is placed in this zone."; type leafref { - path "../zones/zone/name"; + path "../zone/name"; } + mandatory true; } leaf logging { @@ -235,104 +214,94 @@ module infix-firewall { default off; } - container zones { - description "Available firewall zones."; - - list zone { - description "A zone defines a level of trust for network connections."; - key "name"; + list zone { + description "A zone defines a level of trust for network connections."; + key "name"; - leaf name { - description "Name of the zone."; - type string { - length "1..17"; - pattern '[a-zA-Z0-9\-_]+'; - } - } + leaf name { + description "Name of the zone."; + type ident; + } - leaf description { - description "Free-form description of the zone."; - type string; - } + leaf description { + description "Free-form description of the zone."; + type string; + } - leaf policy { - description "Default action for traffic matching no explicit rule. + leaf policy { + description "Default action for traffic matching no explicit rule. Note, see also the 'forwarding' setting for this zone."; - type zone-policy; - default reject; - } + type zone-policy; + default reject; + } - leaf-list interfaces { - description "Interfaces assigned to this zone."; - type if:interface-ref; - } + leaf-list interfaces { + description "Interfaces assigned to this zone."; + type if:interface-ref; + } - leaf-list sources { - description "Source networks assigned to this zone."; - type inet:ip-prefix; - } + leaf-list sources { + description "Source networks assigned to this zone."; + type inet:ip-prefix; + } - leaf forwarding { - description "Allow forwarding between interfaces/sources in the same zone. + leaf forwarding { + description "Allow forwarding between interfaces/sources in the same zone. - Note, this setting applies regardless of the zone policy!"; - type boolean; - default false; - } + Note, this setting applies regardless of (before) the zone policy!"; + type boolean; + } - /* REMOVED: Zone-level masquerade is handled by policy rules instead */ + list port-forward { + description "Forward traffic another port and/or host (DNAT)."; + key "port proto"; - list forward { - description "Forward traffic another port and/or host (DNAT)."; - key "port proto"; + leaf port { + description "Local port to forward from."; + type inet:port-number; + } - leaf port { - description "Local port to forward from."; - type inet:port-number; - } + leaf proto { + description "Network protocol."; + type protocol-type; + } - leaf proto { - description "Network protocol."; - type protocol-type; + container to { + description "Forward matching traffic to given address and port."; + leaf addr { + description "IP address to forward to."; + type inet:ip-address; } - container to { - description "Forward matching traffic to given address and port."; - leaf addr { - description "IP address to forward to."; - type inet:ip-address; - } - - leaf port { - description "Port to forward to."; - type inet:port-number; - } + leaf port { + description "Port to forward to."; + type inet:port-number; } } + } - leaf-list services { - description "Services allowed to ingress this zone."; - type union { - type leafref { - path "../../../services/service/name"; - } - type ifw-svc:well-known-service; + leaf-list services { + description "Services allowed to ingress this zone."; + type union { + type leafref { + path "../../service/name"; } + type ifw-svc:well-known-service; } + } - /* ADVANCED FEATURE: Commented out for simplicity - users rarely need granular ICMP control - list icmp-blocks { - description "ICMP and ICMPv6 types to block."; - key "icmp-type"; + /* ADVANCED FEATURE: Commented out for simplicity - users rarely need granular ICMP control + list icmp-blocks { + description "ICMP and ICMPv6 types to block."; + key "icmp-type"; - leaf icmp-type { - description "ICMP type."; - type icmp-type; - } - } - */ - } + leaf icmp-type { + description "ICMP type."; + type icmp-type; + } + } + */ } list policy { @@ -341,10 +310,7 @@ module infix-firewall { leaf name { description "Unique identifier (filename) for this policy, e.g., LAN-to-WAN."; - type string { - length "1..17"; - pattern '[a-zA-Z0-9\-_]+'; - } + type ident; } leaf description { @@ -377,71 +343,64 @@ module infix-firewall { description "Allowed services, all other traffic follows the policy default action."; type union { type leafref { - path "../../services/service/name"; + path "../../service/name"; } type ifw-svc:well-known-service; } } } - container services { - description "Predefined network services."; - - list service { - description "Manage services, human-friendly names of port+protocol pairs. + list service { + description "Manage services, human-friendly names of port+protocol pairs. A service is a collection of port and protocol pairs. Used by the firewall instead of hard-coding raw port numbers everywhere."; - key "name"; - - leaf name { - description "Name of the service."; - type string { - length "1..17"; - pattern '[a-zA-Z0-9\-_]+'; - } - } - - leaf description { - description "Free-form description of the service."; - type string; - } + key "name"; - list port { - description "Port, or range of ports, and protocol to match."; - key "lower proto"; + leaf name { + description "Name of the service."; + type ident; + } - leaf lower { - type inet:port-number; - description "Lower port in range."; - } + leaf description { + description "Free-form description of the service."; + type string; + } - leaf upper { - type inet:port-number; - must "../lower <= ."; - description "Upper port in range."; - } + list port { + description "Port, or range of ports, and protocol to match."; + key "lower proto"; - leaf proto { - description "Layer 4 protocol."; - type protocol-type; - } + leaf lower { + type inet:port-number; + description "Lower port in range."; + } - /* ADVANCED FEATURE: Commented out for simplicity - rarely needed - leaf match-source { - description "Match on source port(s) instead of default: destination."; - type boolean; - } - */ + leaf upper { + type inet:port-number; + must "../lower <= ."; + description "Upper port in range."; } - /* ADVANCED FEATURE: Commented out for simplicity - service-specific destinations are complex - leaf destination { - type inet:ip-address; - description "Destination IP address/group to match this service to."; + leaf proto { + description "Layer 4 protocol."; + type protocol-type; } + + /* ADVANCED FEATURE: Commented out for simplicity - rarely needed + leaf match-source { + description "Match on source port(s) instead of default: destination."; + type boolean; + } */ } + + /* ADVANCED FEATURE: Commented out for simplicity - service-specific destinations are complex + leaf destination { + type inet:ip-address; + description "Destination IP address/group to match this service to."; + } + */ } } } From ee4573cbb700f0e0b861fa1155fa8ae3a6d8af3a Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 14 Aug 2025 07:30:37 +0200 Subject: [PATCH 10/55] confd: add pre-defined services for dhcpv6 and ipp Signed-off-by: Joachim Wiberg --- src/confd/yang/confd/infix-firewall-services.yang | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/confd/yang/confd/infix-firewall-services.yang b/src/confd/yang/confd/infix-firewall-services.yang index cd5384667..b05f78f8b 100644 --- a/src/confd/yang/confd/infix-firewall-services.yang +++ b/src/confd/yang/confd/infix-firewall-services.yang @@ -28,6 +28,12 @@ module infix-firewall-services { enum "dhcp" { description "udp/67-68 — Dynamic Host Configuration Protocol for network configuration"; } + enum "dhcpv6" { + description "udp/547 — Allow incoming DHCP for IPv6 requests from clients or relay agents."; + } + enum "dhcpv6-client" { + description "udp/546 — Allow a DHCP for IPv6 client to obtain a lease."; + } enum "dns" { description "tcp+udp/53 — Domain Name System for name resolution"; } @@ -46,7 +52,10 @@ module infix-firewall-services { enum "imaps" { description "tcp/993 — Secure Internet Message Access Protocol for encrypted email access"; } - enum "kerberos" { + enum "ipp" { + description "tcp+udp/631 — Internet Printing Protocol (IPP) is used for distributed printing."; + } + enum "kerberos" { description "tcp+udp/88 — Network authentication protocol"; } enum "ldap" { From 40de66bada845ff26618ed3d80b1678b8d57c7dc Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 14 Aug 2025 07:31:07 +0200 Subject: [PATCH 11/55] confd: default firewall settings in factory-config Signed-off-by: Joachim Wiberg --- src/confd/share/factory.d/10-firewall.json | 19 +++++++++++++++++++ src/confd/share/factory.d/Makefile.am | 7 ++++--- 2 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 src/confd/share/factory.d/10-firewall.json diff --git a/src/confd/share/factory.d/10-firewall.json b/src/confd/share/factory.d/10-firewall.json new file mode 100644 index 000000000..c8187a35d --- /dev/null +++ b/src/confd/share/factory.d/10-firewall.json @@ -0,0 +1,19 @@ +{ + "infix-firewall:firewall": { + "default": "internal", + "zone": [ + { + "name": "internal", + "description": "Internal trusted network, forwarding between networks in same zone.", + "policy": "accept", + "forwarding": true + }, + { + "name": "external", + "description": "External untrusted network, only SSH and DHCPv6 client.", + "policy": "drop", + "services": [ "ssh", "dhcpv6-client" ] + } + ] + } +} diff --git a/src/confd/share/factory.d/Makefile.am b/src/confd/share/factory.d/Makefile.am index bc9a9b8fe..d8a69105e 100644 --- a/src/confd/share/factory.d/Makefile.am +++ b/src/confd/share/factory.d/Makefile.am @@ -1,3 +1,4 @@ -factorydir = $(pkgdatadir)/factory.d -dist_factory_DATA = 10-nacm.json 10-netconf-server.json \ - 10-infix-services.json 10-system.json +factorydir = $(pkgdatadir)/factory.d +dist_factory_DATA = 10-nacm.json 10-netconf-server.json \ + 10-infix-services.json 10-system.json \ + 10-firewall.json From df326857e2f7d9ecc78263483753201a86817a65 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 14 Aug 2025 07:31:41 +0200 Subject: [PATCH 12/55] utils: silence srload (debug messages) Signed-off-by: Joachim Wiberg --- utils/srload | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/utils/srload b/utils/srload index 65d9a14a1..11bcbae40 100755 --- a/utils/srload +++ b/utils/srload @@ -84,7 +84,7 @@ enable() { local module=$1 local feature=$2 - echo "*** Enable feature $feature in $module." + #echo "*** Enable feature $feature in $module." $SYSREPOCTL -c $module -e $feature -v2 local rc=$? if [ $rc -ne 0 ]; then @@ -119,7 +119,7 @@ for module in "${MODULES[@]}"; do if [ -z "$SCTL_MODULE" ]; then # prepare command to install module with all its features - echo "*** Installing YANG model $name ..." + #echo "*** Installing YANG model $name ..." install "$module" continue fi @@ -164,7 +164,7 @@ done # install all the new modules if [ -n "${CMD_INSTALL}" ]; then - printf "*** Installing YANG models ...\n%s" "$CMD_INSTALL" + printf "*** Installing YANG models from %s ...\n%s" "$(basename "$1")" "$CMD_INSTALL" eval $CMD_INSTALL rc=$? if [ $rc -ne 0 ]; then From 6be88610bbcd328f876d528595c65f584edc9d5e Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 14 Aug 2025 07:32:03 +0200 Subject: [PATCH 13/55] TODO: firewall Signed-off-by: Joachim Wiberg --- doc/TODO.org | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/doc/TODO.org b/doc/TODO.org index f05fe9f94..67901960d 100644 --- a/doc/TODO.org +++ b/doc/TODO.org @@ -1,3 +1,19 @@ +* TODO Add support for firewall +- [X] Interfaces are not defaulting to the default zone, must handle bridge + ports and changes to enslavement, so regenerate every time is a must! +- [ ] Add missing upper to port forward since port ranges are supported, see services! +- [X] =[do] show firewall= not available yet +- [ ] Add RPC to pause firewall using =firewall-cmd --panic-on= and restart + firewall again with =firewall-cmd --panic-off=. The current state can + be queried using =firewall-cmd --query-panic=, which returns =yes= +- [ ] Remove debug log messages! +- [ ] Add "Log Messages" section to =show firewall= when =LogDenied ≠ off= +- [ ] Rename policy->policy to policy->action, and replace allow->forward +- [ ] Rename zone->sources to networks +- [ ] A zone's policy is for ingress, clarify this if missing! +- [ ] Any services/ports listed in a zone with policy:accept are a NO-OP +- [ ] =firwall-cmd --reload= takes fooooorever! :-( + * TODO doc: User Guide - Feature set and scope, e.g. From da5d306cbf00c034a8c6d95a48c4919620277750 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 14 Aug 2025 15:00:27 +0200 Subject: [PATCH 14/55] confd: new helper function, get all l3 interfaces Used by infix-firewall.c when figuring out interfaces that are not explicitly assigned to any zone. Placing them in the default zone Signed-off-by: Joachim Wiberg --- src/confd/src/ietf-interfaces.c | 66 +++++++++++++++++++++++++++++++++ src/confd/src/ietf-interfaces.h | 1 + 2 files changed, 67 insertions(+) diff --git a/src/confd/src/ietf-interfaces.c b/src/confd/src/ietf-interfaces.c index 28045ff05..574444dba 100644 --- a/src/confd/src/ietf-interfaces.c +++ b/src/confd/src/ietf-interfaces.c @@ -918,6 +918,72 @@ static int keystorecb(sr_session_ctx_t *session, uint32_t sub_id, const char *mo return err; } +int ietf_interfaces_get_all_l3(const struct lyd_node *tree, char ***ifaces) +{ + struct lyd_node *interfaces, *cif; + char **names = NULL; + size_t capacity = 0; + size_t num = 0; + const char *ifname; + + if (!tree || !ifaces) + return -EINVAL; + + *ifaces = NULL; + + interfaces = lydx_get_descendant((struct lyd_node *)tree, "interfaces", "interface", NULL); + if (!interfaces) { + *ifaces = calloc(1, sizeof(char *)); + return *ifaces ? 0 : -ENOMEM; + } + + LYX_LIST_FOR_EACH(interfaces, cif, "interface") { + ifname = lydx_get_cattr(cif, "name"); + if (!ifname) + continue; + + if (is_member_port(cif)) + continue; + + if (iftype_from_iface(cif) == IFT_LO) + continue; + + if (lydx_get_child(cif, "container-network")) + continue; + + if (num + 1 >= capacity) { + capacity = capacity ? capacity * 2 : 8; + char **new_names = realloc(names, capacity * sizeof(char *)); + if (!new_names) { + for (size_t i = 0; i < num; i++) + free(names[i]); + free(names); + return -ENOMEM; + } + names = new_names; + } + + names[num] = strdup(ifname); + if (!names[num]) { + for (size_t i = 0; i < num; i++) + free(names[i]); + free(names); + return -ENOMEM; + } + + num++; + } + + if (num == 0) { + *ifaces = calloc(1, sizeof(char *)); + return *ifaces ? 0 : -ENOMEM; + } + + names[num] = NULL; + *ifaces = names; + return 0; +} + int ietf_interfaces_init(struct confd *confd) { int rc; diff --git a/src/confd/src/ietf-interfaces.h b/src/confd/src/ietf-interfaces.h index 81892ff26..655f6a836 100644 --- a/src/confd/src/ietf-interfaces.h +++ b/src/confd/src/ietf-interfaces.h @@ -102,6 +102,7 @@ int netdag_gen_ethtool(struct dagger *net, struct lyd_node *cif, struct lyd_node /* ietf-interfaces.c */ const char *get_chassis_addr(void); int link_gen_address(struct lyd_node *cif, FILE *ip); +int ietf_interfaces_get_all_l3(const struct lyd_node *tree, char ***ifaces); /* ietf-ip.c */ int netdag_gen_ipv6_autoconf(struct dagger *net, struct lyd_node *cif, From 809d60e969f1ebc1f196c377a56bf0a6f0aafe43 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 14 Aug 2025 15:06:36 +0200 Subject: [PATCH 15/55] confd: find all interfaces not in a zone and assign to default Signed-off-by: Joachim Wiberg --- src/confd/src/infix-firewall.c | 101 +++++++++++++++++++++++++++++++-- 1 file changed, 96 insertions(+), 5 deletions(-) diff --git a/src/confd/src/infix-firewall.c b/src/confd/src/infix-firewall.c index 31a8a7004..ce903927c 100644 --- a/src/confd/src/infix-firewall.c +++ b/src/confd/src/infix-firewall.c @@ -13,6 +13,7 @@ #include #include "core.h" +#include "ietf-interfaces.h" #define MODULE "infix-firewall" #define XPATH "/infix-firewall:firewall" @@ -62,6 +63,23 @@ static const char *policy_action_to_target(const char *action) return policy_action_map[0].yang; } +static void mark_interfaces_used(struct lyd_node *cfg, char **l3_ifaces) +{ + struct lyd_node *node; + + LYX_LIST_FOR_EACH(lyd_child(cfg), node, "interfaces") { + const char *ifname = lyd_get_value(node); + + for (int i = 0; l3_ifaces[i]; i++) { + if (!strcmp(l3_ifaces[i], ifname)) { + l3_ifaces[i][0] = '\0'; + break; + } + } + } +} + + static FILE *open_file(const char *dir, const char *name) { FILE *fp; @@ -92,7 +110,7 @@ static int delete_file(const char *dir, const char *name) return SR_ERR_OK; } -static int generate_zone(const char *name, struct lyd_node *cfg) +static int generate_zone_with_extra_interfaces(const char *name, struct lyd_node *cfg, char **extra_ifaces) { const char *policy, *desc; struct lyd_node *node; @@ -112,6 +130,14 @@ static int generate_zone(const char *name, struct lyd_node *cfg) LYX_LIST_FOR_EACH(lyd_child(cfg), node, "interfaces") fprintf(fp, " \n", lyd_get_value(node)); + + if (extra_ifaces) { + for (int i = 0; extra_ifaces[i]; i++) { + if (extra_ifaces[i][0] != '\0') { + fprintf(fp, " \n", extra_ifaces[i]); + } + } + } LYX_LIST_FOR_EACH(lyd_child(cfg), node, "sources") fprintf(fp, " \n", lyd_get_value(node)); @@ -158,6 +184,43 @@ static int generate_zone(const char *name, struct lyd_node *cfg) return close_file(fp); } +static int generate_zone(const char *name, struct lyd_node *cfg) +{ + return generate_zone_with_extra_interfaces(name, cfg, NULL); +} + +static int generate_default_zone_with_remaining_interfaces(struct lyd_node *tree, char **l3_ifaces) +{ + const char *default_zone = lydx_get_cattr(lydx_get_descendant(tree, "firewall", NULL), "default"); + struct lyd_node *zones, *zone_cfg = NULL; + int unassigned_count = 0; + + for (int i = 0; l3_ifaces[i]; i++) { + if (l3_ifaces[i][0] != '\0') { + unassigned_count++; + } + } + + if (unassigned_count == 0) + return 0; + + zones = lydx_get_descendant(tree, "firewall", "zone", NULL); + LYX_LIST_FOR_EACH(zones, zone_cfg, "zone") { + const char *name = lydx_get_cattr(zone_cfg, "name"); + if (!strcmp(name, default_zone)) + break; + } + + ERROR("Adding %d unassigned interfaces to default zone '%s':", unassigned_count, default_zone); + for (int i = 0; l3_ifaces[i]; i++) { + if (l3_ifaces[i][0] != '\0') { + ERROR(" - %s", l3_ifaces[i]); + } + } + + return generate_zone_with_extra_interfaces(default_zone, zone_cfg, l3_ifaces); +} + static int generate_service(const char *name, struct lyd_node *service_cfg) { const char *desc; @@ -319,17 +382,24 @@ static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module break; } - err = sr_get_data(session, XPATH "//.", 0, 0, 0, &cfg); + err = sr_get_data(session, "//.", 0, 0, 0, &cfg); if (err || !cfg) return SR_ERR_INTERNAL; - + tree = cfg->tree; global = lydx_get_descendant(tree, "firewall", NULL); + + /* Get L3 interfaces for default zone assignment */ + char **l3_ifaces = NULL; + if (ietf_interfaces_get_all_l3(tree, &l3_ifaces) != 0) { + ERROR("Failed to get L3 interfaces"); + l3_ifaces = NULL; + } err = srx_get_diff(session, &diff); if (err) goto err_release_data; - + if (!diff) goto err_release_data; @@ -338,10 +408,12 @@ static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module if (lydx_get_descendant(diff, "firewall", "default", NULL) || lydx_get_descendant(diff, "firewall", "logging", NULL)) { - generate_firewalld_conf(tree); + generate_firewalld_conf(global); reload_needed = true; } + /* Stage 1: Generate explicit zones (skip default zone) */ + const char *default_zone = lydx_get_cattr(global, "default"); list = lydx_get_descendant(diff, "firewall", "zone", NULL); LYX_LIST_FOR_EACH(list, node, "zone") { const char *name = lydx_get_cattr(node, "name"); @@ -357,12 +429,25 @@ static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module if (strcmp(name, lydx_get_cattr(cnode, "name"))) continue; + /* Skip default zone in stage 1 */ + if (!strcmp(name, default_zone)) { + if (l3_ifaces) + mark_interfaces_used(cnode, l3_ifaces); + break; + } + generate_zone(name, cnode); + if (l3_ifaces) + mark_interfaces_used(cnode, l3_ifaces); reload_needed = true; break; } } + /* Stage 2: Generate default zone with remaining interfaces */ + if (l3_ifaces && generate_default_zone_with_remaining_interfaces(tree, l3_ifaces) == 0) + reload_needed = true; + list = lydx_get_descendant(diff, "firewall", "service", NULL); LYX_LIST_FOR_EACH(list, node, "service") { const char *name = lydx_get_cattr(node, "name"); @@ -411,6 +496,12 @@ static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module done: systemf("initctl -nbq %s firewalld", global ? "enable" : "disable"); + if (l3_ifaces) { + for (int i = 0; l3_ifaces[i]; i++) + free(l3_ifaces[i]); + free(l3_ifaces); + } + lyd_free_tree(diff); err_release_data: sr_release_data(cfg); From 551f506917d331b0a87ac2b4b843f424345d1986 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 15 Aug 2025 08:52:29 +0200 Subject: [PATCH 16/55] statd: initial operational support for firewall We have pre-defined policys that have 'continue'. Signed-off-by: Joachim Wiberg --- src/confd/yang/confd/infix-firewall.yang | 5 +- src/statd/python/yanger/__main__.py | 3 + src/statd/python/yanger/infix_firewall.py | 249 ++++++++++++++++++++++ src/statd/statd.c | 3 + 4 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 src/statd/python/yanger/infix_firewall.py diff --git a/src/confd/yang/confd/infix-firewall.yang b/src/confd/yang/confd/infix-firewall.yang index 8f949b00a..c65d83103 100644 --- a/src/confd/yang/confd/infix-firewall.yang +++ b/src/confd/yang/confd/infix-firewall.yang @@ -69,11 +69,10 @@ module infix-firewall { typedef policy-action { type enumeration { - /* ADVANCED FEATURE: Commented out - requires policy ordering/priority system + /* ADVANCED FEATURE: Commented out - requires policy ordering/priority system */ enum continue { description "Non-terminal policy. Matching traffic is accepted or allowed to proceed, and other policies continue to be evaluated."; } - */ enum accept { description "Accept matching traffic and stop evaluating further policies."; } @@ -231,7 +230,7 @@ module infix-firewall { leaf policy { description "Default action for traffic matching no explicit rule. - Note, see also the 'forwarding' setting for this zone."; + Note, see also the 'forwarding' setting for this zone."; type zone-policy; default reject; } diff --git a/src/statd/python/yanger/__main__.py b/src/statd/python/yanger/__main__.py index bf69178f8..172f9de16 100644 --- a/src/statd/python/yanger/__main__.py +++ b/src/statd/python/yanger/__main__.py @@ -78,6 +78,9 @@ def dirpath(path): elif args.model == 'ieee802-dot1ab-lldp': from . import infix_lldp yang_data = infix_lldp.operational() + elif args.model == 'infix-firewall': + from . import infix_firewall + yang_data = infix_firewall.operational() else: common.LOG.warning("Unsupported model %s", args.model) sys.exit(1) diff --git a/src/statd/python/yanger/infix_firewall.py b/src/statd/python/yanger/infix_firewall.py new file mode 100644 index 000000000..2a5ff9e9e --- /dev/null +++ b/src/statd/python/yanger/infix_firewall.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +""" +Collect operational data for infix-firewall.yang from firewalld using D-Bus, +for the full API, see: + + gdbus introspect --system --dest org.fedoraproject.FirewallD1 \ + --object-path /org/fedoraproject/FirewallD1 +""" +import dbus +from . import common + + +def get_interface(interface = "org.fedoraproject.FirewallD1"): + try: + bus = dbus.SystemBus() + obj = bus.get_object("org.fedoraproject.FirewallD1", + "/org/fedoraproject/FirewallD1") + return dbus.Interface(obj, interface) + + except dbus.exceptions.DBusException as e: + common.LOG.warning("Failed to connect to firewalld D-Bus: %s", e) + return None + + +def get_zone_data(fw, name): + try: + settings = fw.getZoneSettings2(name) + + zone = { + "name": name, + "policy": "accept", + "interfaces": list(settings.get('interfaces', [])), + # "sources": list(settings.get('sources', [])), + "services": list(settings.get('services', [])), + "port-forward": [], + "forwarding": False + } + + target = settings.get('target', 'default') + policy = { + "%%REJECT%%": "reject", + "REJECT": "reject", + "ACCEPT": "accept", + "DROP": "drop", + "default": "accept" + } + zone["policy"] = policy.get(target, "accept") + + forwards = settings.get('forward_ports', []) + for fwd in forwards: + fwd_data = {} + port_data = {} + + if len(fwd) >= 4: + port, protocol, toaddr, toport = fwd[:4] + + if '-' in str(port): + lower, upper = str(port).split('-', 1) + port_data['lower'] = int(lower) + port_data['upper'] = int(upper) + else: + port_data['lower'] = int(port) + port_data['proto'] = protocol + + fwd_data['port'] = port_data + fwd_data['to'] = {'addr': toaddr} + + if '-' in str(toport): + lower, upper = str(toport).split('-', 1) + fwd_data['to']['lower'] = int(lower) + fwd_data['to']['upper'] = int(upper) + else: + fwd_data['to']['lower'] = int(toport) + + zone["port-forward"].append(fwd_data) + + zone["forwarding"] = bool(settings.get('forward', 0)) + return zone + + except Exception as e: + common.LOG.warning("Failed querying zone %s via D-Bus: %s", name, e) + return None + + +def get_zones(fw): + zones = [] + try: + fw = get_interface("org.fedoraproject.FirewallD1.zone") + if not fw: + return zones + + for name in fw.getZones(): + zone_data = get_zone_data(fw, name) + if zone_data: + zones.append(zone_data) + + except Exception as e: + common.LOG.warning("Failed querying zones: %s", e) + + return zones + + +def get_policy_data(fw, name): + try: + settings = fw.getPolicySettings(name) + + policy = { + "name": name, + "policy": "reject", + # "priority": 32767, + "ingress": [], + "egress": [] + } + + target = settings.get('target', 'CONTINUE') + action = { + "CONTINUE": "continue", + "ACCEPT": "accept", + "REJECT": "reject", + "DROP": "drop" + } + policy["policy"] = action.get(target, "reject") + + # priority = settings.get('priority', 32767) + # if isinstance(priority, int): + # policy["priority"] = priority + + description = settings.get('description', '') + if description: + policy["description"] = description + + ingress = settings.get('ingress_zones', []) + if ingress: + policy["ingress"] = list(ingress) + + egress = settings.get('egress', []) + if egress: + policy["egress"] = list(egress) + + services = settings.get('services', []) + if services: + policy["service"] = list(services) + + policy["masquerade"] = bool(settings.get('masquerade', 0)) + + return policy + + except Exception as e: + common.LOG.warning("Failed querying policy %s via D-Bus: %s", name, e) + return None + + +def get_policies(fw): + policies = [] + try: + fw = get_interface("org.fedoraproject.FirewallD1.policy") + if not fw: + return policies + + for name in fw.getPolicies(): + data = get_policy_data(fw, name) + if data: + policies.append(data) + + except Exception as e: + common.LOG.warning("Failed querying policies: %s", e) + + return policies + + +def get_service_data(fw, name): + try: + settings = fw.getServiceSettings2(name) + + service = { + "name": name, + "port": [] + } + + description = settings.get('description', '') + if description: + service["description"] = description + + ports = settings.get('ports', []) + for port_info in ports: + if len(port_info) >= 2: + port, protocol = port_info[:2] + port_data = {'proto': protocol} + + if '-' in str(port): + lower, upper = str(port).split('-', 1) + port_data['lower'] = int(lower) + port_data['upper'] = int(upper) + else: + port_data['lower'] = int(port) + + service["port"].append(port_data) + + return service + + except Exception as e: + common.LOG.warning("Failed querying service %s via D-Bus: %s", name, e) + return None + + +def get_services(fw): + services = [] + try: + for name in fw.listServices(): + data = get_service_data(fw, name) + if data: + services.append(data) + + except Exception as e: + common.LOG.warning("Failed querying services: %s", e) + + return services + + +def operational(): + try: + fw = get_interface() + if not fw: + return {} + + except Exception as e: + common.LOG.warning("Failed checking firewalld state: %s", e) + return {} + + data = { + "infix-firewall:firewall": { + "default": fw.getDefaultZone(), + "logging": fw.getLogDenied() + } + } + + zones = get_zones(fw) + if zones: + data["infix-firewall:firewall"]["zone"] = zones + + policies = get_policies(fw) + if policies: + data["infix-firewall:firewall"]["policy"] = policies + + services = get_services(fw) + if services: + data["infix-firewall:firewall"]["service"] = services + + return data diff --git a/src/statd/statd.c b/src/statd/statd.c index f69894b45..dcd8738c3 100644 --- a/src/statd/statd.c +++ b/src/statd/statd.c @@ -43,6 +43,7 @@ #define XPATH_CONTAIN_BASE "/infix-containers:containers" #define XPATH_DHCP_SERVER_BASE "/infix-dhcp-server:dhcp-server" #define XPATH_LLDP_BASE "/ieee802-dot1ab-lldp:lldp" +#define XPATH_FIREWALL_BASE "/infix-firewall:firewall" TAILQ_HEAD(sub_head, sub); @@ -356,6 +357,8 @@ static int subscribe_to_all(struct statd *statd) #endif if (subscribe(statd, "infix-dhcp-server", XPATH_DHCP_SERVER_BASE, sr_generic_cb)) return SR_ERR_INTERNAL; + if (subscribe(statd, "infix-firewall", XPATH_FIREWALL_BASE, sr_generic_cb)) + return SR_ERR_INTERNAL; INFO("Successfully subscribed to all models"); return SR_ERR_OK; From d188a954e2818bd5a7700cd169d3abbbb9049a18 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 15 Aug 2025 21:01:40 +0200 Subject: [PATCH 17/55] fixup swap proto/port Signed-off-by: Joachim Wiberg --- .../yang/confd/infix-firewall-services.yang | 94 +++++++++---------- 1 file changed, 45 insertions(+), 49 deletions(-) diff --git a/src/confd/yang/confd/infix-firewall-services.yang b/src/confd/yang/confd/infix-firewall-services.yang index b05f78f8b..3a49f4f7e 100644 --- a/src/confd/yang/confd/infix-firewall-services.yang +++ b/src/confd/yang/confd/infix-firewall-services.yang @@ -5,17 +5,10 @@ module infix-firewall-services { organization "KernelKit"; contact "kernelkit@googlegroups.com"; - description "Well-defined network services for Infix firewall. - - This module defines an enumeration of well-known network services - following the pattern established by iana-timezones.yang, where each - service is represented as an enum value with a descriptive text - explaining what the service does and the ports/protocols it uses."; + description "Common well-defined network services."; revision 2025-04-26 { - description "Refactored to use enum-based approach similar to iana-timezones.yang. - Each well-known service is now defined as an enum value with - human-friendly descriptions covering purpose and technical details."; + description "Initial revision."; reference "internal"; } @@ -23,124 +16,127 @@ module infix-firewall-services { description "Well-known network services, with standard port assignments from IANA."; type enumeration { enum "bgp" { - description "tcp/179 — Border Gateway Protocol for internet routing"; + description "179/tcp — Border Gateway Protocol for internet routing"; } enum "dhcp" { - description "udp/67-68 — Dynamic Host Configuration Protocol for network configuration"; + description "67-68/udp — Dynamic Host Configuration Protocol for network configuration"; } enum "dhcpv6" { - description "udp/547 — Allow incoming DHCP for IPv6 requests from clients or relay agents."; + description "547/udp — Allow incoming DHCP for IPv6 requests from clients or relay agents."; } enum "dhcpv6-client" { - description "udp/546 — Allow a DHCP for IPv6 client to obtain a lease."; + description "546/udp — Allow a DHCP for IPv6 client to obtain a lease."; } enum "dns" { - description "tcp+udp/53 — Domain Name System for name resolution"; + description "53/tcp+udp — Domain Name System for name resolution"; } enum "ftp" { - description "tcp/20-21 — File Transfer Protocol for file transfers"; + description "20-21/tcp — File Transfer Protocol for file transfers"; } enum "http" { - description "tcp/80 — Hypertext Transfer Protocol for web traffic"; + description "80/tcp — Hypertext Transfer Protocol for web traffic"; } enum "https" { - description "tcp/443 — Secure Hypertext Transfer Protocol for encrypted web traffic"; + description "443/tcp — Secure Hypertext Transfer Protocol for encrypted web traffic"; } enum "imap" { - description "tcp/143 — Internet Message Access Protocol for email access"; + description "143/tcp — Internet Message Access Protocol for email access"; } enum "imaps" { - description "tcp/993 — Secure Internet Message Access Protocol for encrypted email access"; + description "993/tcp — Secure Internet Message Access Protocol for encrypted email access"; } enum "ipp" { - description "tcp+udp/631 — Internet Printing Protocol (IPP) is used for distributed printing."; + description "631/tcp+udp — Internet Printing Protocol (IPP) is used for distributed printing."; } enum "kerberos" { - description "tcp+udp/88 — Network authentication protocol"; + description "88/tcp+udp — Network authentication protocol"; } enum "ldap" { - description "tcp/389 — Lightweight Directory Access Protocol for directory services"; + description "389/tcp — Lightweight Directory Access Protocol for directory services"; } enum "mdns" { - description "udp/5353 — Multicast DNS for local network service discovery"; + description "5353/udp — Multicast DNS for local network service discovery"; } enum "mongodb" { - description "tcp/27017 — Document-oriented NoSQL database"; + description "27017/tcp — Document-oriented NoSQL database"; } enum "mqtt" { - description "tcp/1883 — Message Queuing Telemetry Transport for IoT"; + description "1883/tcp — Message Queuing Telemetry Transport for IoT"; } enum "mssql" { - description "tcp/1433 — Microsoft SQL Server database"; + description "1433/tcp — Microsoft SQL Server database"; } enum "mysql" { - description "tcp/3306 — MySQL database server connections"; + description "3306/tcp — MySQL database server connections"; } enum "netbios-ns" { - description "udp/137 — NetBIOS Name Service for Windows networking"; + description "137/udp — NetBIOS Name Service for Windows networking"; } enum "nfs" { - description "tcp+udp/2049 — Network File System for distributed file sharing"; + description "2049/tcp+udp — Network File System for distributed file sharing"; } enum "ntp" { - description "udp/123 — Network Time Protocol for time synchronization"; + description "123/udp — Network Time Protocol for time synchronization"; } enum "openvpn" { - description "udp/1194 — OpenVPN secure tunnel for VPN connections"; + description "1194/udp — OpenVPN secure tunnel for VPN connections"; } enum "pop3" { - description "tcp/110 — Post Office Protocol version 3 for email retrieval"; + description "110/tcp — Post Office Protocol version 3 for email retrieval"; } enum "pop3s" { - description "tcp/995 — Secure Post Office Protocol version 3 for encrypted email retrieval"; + description "995/tcp — Secure Post Office Protocol version 3 for encrypted email retrieval"; } enum "postgresql" { - description "tcp/5432 — PostgreSQL database server connections"; + description "5432/tcp — PostgreSQL database server connections"; } enum "radius" { - description "tcp+udp/1812-1813 — Remote Authentication Dial-in User Service"; + description "1812-1813/tcp+udp — Remote Authentication Dial-in User Service"; } enum "rdp" { - description "tcp/3389 — Remote Desktop Protocol for Windows remote access"; + description "3389/tcp — Remote Desktop Protocol for Windows remote access"; } enum "samba" { - description "tcp/445 — Windows file and printer sharing"; + description "445/tcp — Windows file and printer sharing"; + } + enum "samba-client" { + description "138/udp — Windows file and printer sharing (client-only)"; } enum "sip" { - description "tcp+udp/5060 — Session Initiation Protocol for VoIP communications"; + description "5060/tcp+udp — Session Initiation Protocol for VoIP communications"; } enum "sips" { - description "tcp+udp/5061 — Secure Session Initiation Protocol for encrypted VoIP"; + description "5061/tcp+udp — Secure Session Initiation Protocol for encrypted VoIP"; } enum "smtp" { - description "tcp/25 — Simple Mail Transfer Protocol for email transmission"; + description "25/tcp — Simple Mail Transfer Protocol for email transmission"; } enum "snmp" { - description "udp/161 — Simple Network Management Protocol for network monitoring"; + description "161/udp — Simple Network Management Protocol for network monitoring"; } enum "snmptrap" { - description "udp/162 — Simple Network Management Protocol trap notifications"; + description "162/udp — Simple Network Management Protocol trap notifications"; } enum "ssh" { - description "tcp/22 — Secure Shell for remote login and command execution"; + description "22/tcp — Secure Shell for remote login and command execution"; } enum "ssdp" { - description "udp/1900 — Simple Service Discovery Protocol for UPnP device discovery"; + description "1900/udp — Simple Service Discovery Protocol for UPnP device discovery"; } enum "telnet" { - description "tcp/23 — Telnet protocol for remote terminal access"; + description "23/tcp — Telnet protocol for remote terminal access"; } enum "tftp" { - description "udp/69 — Trivial File Transfer Protocol for simple file transfers"; + description "69/udp — Trivial File Transfer Protocol for simple file transfers"; } enum "vnc-server" { - description "tcp/5900-5906 — Virtual Network Computing server for remote desktop access"; + description "5900-5906/tcp — Virtual Network Computing server for remote desktop access"; } enum "wireguard" { - description "udp/51820 — Modern VPN tunnel for secure networking"; + description "51820/udp — Modern VPN tunnel for secure networking"; } enum "xdmcp" { - description "tcp+udp/177 — X Display Manager Control Protocol for remote X11 sessions"; + description "177/tcp+udp — X Display Manager Control Protocol for remote X11 sessions"; } } } From d85186d22d08741c800e9ec31018e3a1f154400d Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 15 Aug 2025 21:02:04 +0200 Subject: [PATCH 18/55] confd: clean up unused pre-defined services Signed-off-by: Joachim Wiberg --- package/confd/confd.mk | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/package/confd/confd.mk b/package/confd/confd.mk index 6db96c7a6..7694cdd36 100644 --- a/package/confd/confd.mk +++ b/package/confd/confd.mk @@ -14,6 +14,7 @@ CONFD_DEPENDENCIES = host-sysrepo sysrepo netopeer2 jansson libite sysrepo libsr CONFD_AUTORECONF = YES CONFD_CONF_OPTS += --disable-silent-rules --with-crypt=$(BR2_PACKAGE_CONFD_DEFAULT_CRYPT) CONFD_SYSREPO_SHM_PREFIX = sr_buildroot$(subst /,_,$(CONFIG_DIR))_confd +CONFD_FIREWALL_SERVICES_YANG = $(CONFD_SRCDIR)/yang/confd/infix-firewall-services.yang define CONFD_CONF_ENV CFLAGS="$(INFIX_CFLAGS)" @@ -103,6 +104,8 @@ endef # The three zones that are *not* deleted from the default install # are required by firewalld (core/fw.py), in particular block.xml +# Firewalld services cleanup: keep only services that match YANG enums, +# remove all others, and validate that all enums have corresponding .xml files define CONFD_CLEANUP rm -f /dev/shm/$(CONFD_SYSREPO_SHM_PREFIX)* rm -rf $(TARGET_DIR)/etc/firewall* @@ -117,6 +120,29 @@ define CONFD_CLEANUP mkdir -p $(TARGET_DIR)/etc/firewalld/policies mkdir -p $(TARGET_DIR)/etc/firewalld/services touch $(TARGET_DIR)/etc/firewalld/firewalld.conf + if [ ! -f "$(CONFD_FIREWALL_SERVICES_YANG)" ]; then \ + echo "ERROR: $(CONFD_FIREWALL_SERVICES_YANG) not found"; \ + exit 1; \ + fi; \ + ENUMS=$$(grep 'enum "' $(CONFD_FIREWALL_SERVICES_YANG) | \ + sed 's/.*enum "\([^"]*\)".*/\1/'); \ + MISSING=0; \ + for service in $$ENUMS; do \ + if [ ! -f "$(TARGET_DIR)/usr/lib/firewalld/services/$$service.xml" ]; then \ + echo "Service $$service is not a firewalld pre-defined service"; \ + MISSING=1; \ + fi; \ + done; \ + if [ $$MISSING -eq 1 ]; then \ + exit 1; \ + fi; \ + cd $(TARGET_DIR)/usr/lib/firewalld/services/; \ + for xmlfile in *.xml; do \ + service=$${xmlfile%.xml}; \ + if ! echo "$$ENUMS" | grep -q "^$$service$$"; then \ + rm "$$xmlfile"; \ + fi; \ + done endef CONFD_PRE_BUILD_HOOKS += CONFD_EMPTY_SYSREPO CONFD_PRE_BUILD_HOOKS += CONFD_CLEANUP From da02a85b342d3607548b091a68e6690b3b35574b Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 16 Aug 2025 06:56:27 +0200 Subject: [PATCH 19/55] confd: change to singular form and add must expression Signed-off-by: Joachim Wiberg --- src/confd/share/factory.d/10-firewall.json | 4 ++-- src/confd/src/infix-firewall.c | 19 ++++++++++--------- src/confd/yang/confd/infix-firewall.yang | 14 +++++++++----- src/statd/python/yanger/infix_firewall.py | 6 +++--- 4 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/confd/share/factory.d/10-firewall.json b/src/confd/share/factory.d/10-firewall.json index c8187a35d..876336468 100644 --- a/src/confd/share/factory.d/10-firewall.json +++ b/src/confd/share/factory.d/10-firewall.json @@ -10,9 +10,9 @@ }, { "name": "external", - "description": "External untrusted network, only SSH and DHCPv6 client.", + "description": "External untrusted network, only SSH and DHCPv6 client allowed in.", "policy": "drop", - "services": [ "ssh", "dhcpv6-client" ] + "service": [ "ssh", "dhcpv6-client" ] } ] } diff --git a/src/confd/src/infix-firewall.c b/src/confd/src/infix-firewall.c index ce903927c..4216f94c6 100644 --- a/src/confd/src/infix-firewall.c +++ b/src/confd/src/infix-firewall.c @@ -67,7 +67,7 @@ static void mark_interfaces_used(struct lyd_node *cfg, char **l3_ifaces) { struct lyd_node *node; - LYX_LIST_FOR_EACH(lyd_child(cfg), node, "interfaces") { + LYX_LIST_FOR_EACH(lyd_child(cfg), node, "interface") { const char *ifname = lyd_get_value(node); for (int i = 0; l3_ifaces[i]; i++) { @@ -127,8 +127,8 @@ static int generate_zone_with_extra_interfaces(const char *name, struct lyd_node fprintf(fp, " %s\n", name); if (desc) fprintf(fp, " %s\n", desc); - - LYX_LIST_FOR_EACH(lyd_child(cfg), node, "interfaces") + + LYX_LIST_FOR_EACH(lyd_child(cfg), node, "interface") fprintf(fp, " \n", lyd_get_value(node)); if (extra_ifaces) { @@ -138,13 +138,13 @@ static int generate_zone_with_extra_interfaces(const char *name, struct lyd_node } } } - - LYX_LIST_FOR_EACH(lyd_child(cfg), node, "sources") + + LYX_LIST_FOR_EACH(lyd_child(cfg), node, "source") fprintf(fp, " \n", lyd_get_value(node)); - - LYX_LIST_FOR_EACH(lyd_child(cfg), node, "services") + + LYX_LIST_FOR_EACH(lyd_child(cfg), node, "service") fprintf(fp, " \n", lyd_get_value(node)); - + LYX_LIST_FOR_EACH(lyd_child(cfg), node, "port-forward") { const char *port = lydx_get_cattr(node, "port"); const char *proto = lydx_get_cattr(node, "proto"); @@ -352,7 +352,8 @@ static int infer_zone(sr_session_ctx_t *session, const char *name, const char *d return rc; for (int i = 0; services && services[i]; i++) { - rc = srx_set_str(session, services[i], 0, XPATH "/zone[name='%s']/services[.='%s']", name, services[i]); + rc = srx_set_str(session, services[i], 0, XPATH "/zone[name='%s']/service[.='%s']", + name, services[i]); if (rc) return rc; } diff --git a/src/confd/yang/confd/infix-firewall.yang b/src/confd/yang/confd/infix-firewall.yang index c65d83103..af5edbafe 100644 --- a/src/confd/yang/confd/infix-firewall.yang +++ b/src/confd/yang/confd/infix-firewall.yang @@ -235,18 +235,22 @@ module infix-firewall { default reject; } - leaf-list interfaces { - description "Interfaces assigned to this zone."; + leaf-list interface { + description "List of interfaces assigned to this zone."; type if:interface-ref; + + must "count(/firewall/zone[interface = current()]) <= 1" { + error-message "An interface can only be assigned to one firewall zone"; + } } - leaf-list sources { + leaf-list source { description "Source networks assigned to this zone."; type inet:ip-prefix; } leaf forwarding { - description "Allow forwarding between interfaces/sources in the same zone. + description "Allow forwarding between interfaces/networks in the same zone. Note, this setting applies regardless of (before) the zone policy!"; type boolean; @@ -280,7 +284,7 @@ module infix-firewall { } } - leaf-list services { + leaf-list service { description "Services allowed to ingress this zone."; type union { type leafref { diff --git a/src/statd/python/yanger/infix_firewall.py b/src/statd/python/yanger/infix_firewall.py index 2a5ff9e9e..9c6783a28 100644 --- a/src/statd/python/yanger/infix_firewall.py +++ b/src/statd/python/yanger/infix_firewall.py @@ -29,9 +29,9 @@ def get_zone_data(fw, name): zone = { "name": name, "policy": "accept", - "interfaces": list(settings.get('interfaces', [])), - # "sources": list(settings.get('sources', [])), - "services": list(settings.get('services', [])), + "interface": list(settings.get('interfaces', [])), + "source": list(settings.get('sources', [])), + "service": list(settings.get('services', [])), "port-forward": [], "forwarding": False } From 24550cee0c3ca299ee22972bca0b3d7848f2ade4 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 17 Aug 2025 15:06:54 +0200 Subject: [PATCH 20/55] klish-plugin-infix: minor, xml optimization Signed-off-by: Joachim Wiberg --- src/klish-plugin-infix/xml/infix.xml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/klish-plugin-infix/xml/infix.xml b/src/klish-plugin-infix/xml/infix.xml index f1421986c..cc3bde08f 100644 --- a/src/klish-plugin-infix/xml/infix.xml +++ b/src/klish-plugin-infix/xml/infix.xml @@ -317,8 +317,7 @@ - - + @@ -389,8 +388,7 @@ - - + From c5295be5f19eabc7bbfc033ba0bd2bdffb2a03b0 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 17 Aug 2025 15:08:50 +0200 Subject: [PATCH 21/55] cli: add support for 'show firewall [..]' admin-exec commands Signed-off-by: Joachim Wiberg --- src/klish-plugin-infix/src/infix.c | 30 ++ src/klish-plugin-infix/xml/infix.xml | 53 +++ src/statd/python/cli_pretty/cli_pretty.py | 450 +++++++++++++++++++++- src/statd/python/yanger/infix_firewall.py | 93 +++-- test/case/statd/system/cli/show-hardware | 3 +- 5 files changed, 577 insertions(+), 52 deletions(-) diff --git a/src/klish-plugin-infix/src/infix.c b/src/klish-plugin-infix/src/infix.c index 54bae6554..0073afaeb 100644 --- a/src/klish-plugin-infix/src/infix.c +++ b/src/klish-plugin-infix/src/infix.c @@ -142,6 +142,33 @@ int infix_ifaces(kcontext_t *ctx) return 0; } +static int firewall_completion(const char *type) +{ + const char *cmd = "sysrepocfg -X -d operational -f json -x"; + + return systemf("%s /infix-firewall:firewall/%s/name 2>/dev/null" + " | jq -r '.\"infix-firewall:firewall\".%s[]?.name // empty'" + " 2>/dev/null", cmd, type, type); +} + +int infix_firewall_zones(kcontext_t *ctx) +{ + (void)ctx; + return firewall_completion("zone"); +} + +int infix_firewall_policies(kcontext_t *ctx) +{ + (void)ctx; + return firewall_completion("policy"); +} + +int infix_firewall_services(kcontext_t *ctx) +{ + (void)ctx; + return firewall_completion("service"); +} + int infix_copy(kcontext_t *ctx) { kpargv_t *pargv = kcontext_pargv(ctx); @@ -228,6 +255,9 @@ int kplugin_infix_init(kcontext_t *ctx) kplugin_add_syms(plugin, ksym_new("erase", infix_erase)); kplugin_add_syms(plugin, ksym_new("files", infix_files)); kplugin_add_syms(plugin, ksym_new("ifaces", infix_ifaces)); + 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("shell", infix_shell)); return 0; diff --git a/src/klish-plugin-infix/xml/infix.xml b/src/klish-plugin-infix/xml/infix.xml index cc3bde08f..2a11c98ee 100644 --- a/src/klish-plugin-infix/xml/infix.xml +++ b/src/klish-plugin-infix/xml/infix.xml @@ -113,6 +113,27 @@ + + + + + + + + + + + + + + + + + + + + + @@ -490,6 +511,38 @@ jq -C . /cfg/startup-config.cfg |pager + + + + + + + + + sysrepocfg -X -d operational -x /infix-firewall:firewall -f json -t 60 | /usr/libexec/statd/cli-pretty show-firewall-zone "$KLISH_PARAM_name" |pager + + + + + + + + sysrepocfg -X -d operational -x /infix-firewall:firewall -f json -t 60 | /usr/libexec/statd/cli-pretty show-firewall-policy "$KLISH_PARAM_name" |pager + + + + + + + + sysrepocfg -X -d operational -x /infix-firewall:firewall -f json -t 60 | /usr/libexec/statd/cli-pretty show-firewall-service "$KLISH_PARAM_name" |pager + + + + + sysrepocfg -X -d operational -x /infix-firewall:firewall -f json -t 60 | /usr/libexec/statd/cli-pretty show-firewall |pager + + diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index 232818ce3..ed6f2dc2c 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -3,6 +3,7 @@ import argparse import sys import re +import textwrap from datetime import datetime, timezone UNIT_TEST = False @@ -96,6 +97,34 @@ class PadLldp: port_id = 20 +class PadFirewall: + zone_name = 20 + zone_action = 9 + zone_interfaces = 40 + zone_services = 20 + + zone_pfwd_from = 20 + zone_pfwd_to = 69 + + zone_flow_to = 20 + zone_flow_action = 9 + zone_flow_policy = 60 + + policy_name = 20 + policy_action = 9 + policy_ingress = 40 + policy_egress = 20 + + service_name = 20 + service_ports = 69 + + @classmethod + def table_width(cls): + """Table width for zones/policies tables, used to center matrix""" + return cls.zone_name + cls.zone_action + cls.zone_interfaces \ + + cls.zone_services + + class Decore(): @staticmethod def decorate(sgr, txt, restore="0"): @@ -133,6 +162,22 @@ def underline(txt): def gray_bg(txt): return Decore.decorate("100", txt) + @staticmethod + def title(txt, len=None, bold=True): + """Print section header with horizontal bar line above it + Args: + txt: The header text to display + len: Length of horizontal bar line (defaults to len(txt)) + bold: Whether to make the text bold + """ + length = len if len is not None else len(txt) + underline = "─" * length + print(underline) + if bold: + print(Decore.bold(txt)) + else: + print(txt) + def rssi_to_status(rssi): if rssi <= -75: @@ -171,6 +216,24 @@ def remove_yang_prefix(key): return key +def format_description(label, description, width=60): + """Format description text with proper line wrapping""" + if not description: + return f"{label:<20}:" + + lines = textwrap.wrap(description, width=width) + if not lines: + return f"{label:<20}:" + + # First line with label + result = f"{label:<20}: {lines[0]}" + # Subsequent lines indented + for line in lines[1:]: + result += f"\n{'':<20} {line}" + + return result + + class Date(datetime): def _pretty_delta(delta): assert(delta.total_seconds() > 0) @@ -652,16 +715,15 @@ def pr_proto_loopack(self, pipe=''): print(row) def pr_wifi_ssids(self): - hdr = (f"{'SSID':<{PadWifiScan.ssid}}" - f"{'ENCRYPTION':<{PadWifiScan.encryption}}" - f"{'SIGNAL':<{PadWifiScan.signal}}" - ) + hdr = (f"{'SSID':<{PadWifiScan.ssid}}" + f"{'ENCRYPTION':<{PadWifiScan.encryption}}" + f"{'SIGNAL':<{PadWifiScan.signal}}") print(Decore.invert(hdr)) - results=self.wifi.get("scan-results", {}) + results = self.wifi.get("scan-results", {}) for result in results: - encstr = ",".join(result["encryption"]) - status=rssi_to_status(result["rssi"]) + encstr = ", ".join(result["encryption"]) + status = rssi_to_status(result["rssi"]) row = f"{result['ssid']:<{PadWifiScan.ssid}}" row += f"{encstr:<{PadWifiScan.encryption}}" row += f"{status:<{PadWifiScan.signal}}" @@ -1329,13 +1391,12 @@ def show_software(json, name): def show_hardware(json): if not json.get("ietf-hardware:hardware"): - print(f"Error, top level \"ietf-hardware:component\" missing") - sys.exit(1) + print("Error, top level \"ietf-hardware:component\" missing") + sys.exit(1) - hdr = (f"{'USB PORTS':<{PadUsbPort.title}}") - print(Decore.invert(hdr)) - hdr = (f"{'NAME':<{PadUsbPort.name}}" - f"{'STATE':<{PadUsbPort.state}}") + hdr = (f"{'NAME':<{PadUsbPort.name}}" + f"{'STATE':<{PadUsbPort.state}}") + Decore.title("USB PORTS", PadUsbPort.title) # TODO: could be len(hdr) print(Decore.invert(hdr)) components = get_json_data({}, json, "ietf-hardware:hardware", "component") @@ -1417,6 +1478,354 @@ def show_lldp(json): entry.print() +def show_firewall(json): + """Show firewall overview with matrix and tables""" + fw = json.get('infix-firewall:firewall', {}) + if not fw: + print("Firewall disabled.") + return + + # Adjust 20 + 8, where 8 is len(bold) + len(restore) + print(f"{Decore.bold('Firewall'):<28}: enabled") # TODO: 'pause' RPC state + print(f"{Decore.bold('Default zone'):<28}: {fw.get('default', 'unknown')}") + print(f"{Decore.bold('Log denied traffic'):<28}: {fw.get('logging', 'off')}") + + show_firewall_matrix(fw) + show_firewall_zone(json) + show_firewall_policy(json) + + +def build_policy_map(policies): + """Build enhanced policy lookup with conditional detection""" + policy_map = {} + + for policy in policies: + ingress_zones = policy.get('ingress', []) + egress_zones = policy.get('egress', []) + services = policy.get('service', []) + policy_action = policy.get('policy', 'reject') + policy_name = policy.get('name', 'unknown') + + for ing in ingress_zones: + for egr in egress_zones: + if ing != 'ANY' and egr != 'ANY': + key = (ing, egr) + + if key not in policy_map: + policy_map[key] = { + 'allow': False, + 'conditional': False, + 'services': set(), + 'policies': [] + } + + if policy_action in ['accept', 'continue']: + policy_map[key]['allow'] = True + + if services: + policy_map[key]['conditional'] = True + policy_map[key]['services'].update(services) + + policy_map[key]['policies'].append(policy_name) + return policy_map + + +def traffic_symbol(from_zone, to_zone, policy_map, zones): + """Determine the symbol to show for zone-to-zone traffic""" + if from_zone == to_zone: + return "✓" + + key = (from_zone, to_zone) + policy = policy_map.get(key) + + if not policy or not policy['allow']: + # Check if from_zone has port forwarding rules (makes it conditional) + zone = next((z for z in zones if z.get('name') == from_zone), None) + if zone: + action = zone.get('policy', 'accept') + pfwd = zone.get('port-forward', []) + if action in ['reject', 'drop'] and pfwd: + return "⚠" # Some traffic allowed via port forwarding + + return "✗" + + if policy['conditional']: + return "⚠" + + return "✓" + + +def show_firewall_matrix(fw): + """Show zone-to-zone traffic matrix""" + zones = fw.get('zone', []) + policies = fw.get('policy', []) + + # No need for a matrix if there's only one zone + if len(zones) <= 1: + return None + + zone_names = [z['name'] for z in zones if z.get('interface')] + if len(zone_names) <= 1: + return None + + # Build enhanced policy lookup map + policy_map = build_policy_map(policies) + + max_zone_len = max(len(zone) for zone in zone_names) + col_width = max(max_zone_len, 1) # At least 1 char for symbols + left_col_width = max_zone_len + + # Box drawing characters for proper borders, '+ 2' is for spacing + top_border = "┌" + "─" * left_col_width + "──" + "┬" + for _ in zone_names: + top_border += "─" * (col_width + 2) + "┬" + top_border = top_border[:-1] + "┐" # Replace last ┬ with ┐ + + middle_border = "├" + "─" * left_col_width + "──" + "┼" + for _ in zone_names: + middle_border += "─" * (col_width + 2) + "┼" + middle_border = middle_border[:-1] + "┤" # Replace last ┼ with ┤ + + bottom_border = "└" + "─" * left_col_width + "──" + "┴" + for _ in zone_names: + bottom_border += "─" * (col_width + 2) + "┴" + bottom_border = bottom_border[:-1] + "┘" # Replace last ┴ with ┘ + + # Header with arrow in top-left cell + hdr = f"│ {'→':^{left_col_width}} │" + for zone in zone_names: + hdr += f" {zone:^{col_width}} │" + + # Calculate centering relative to zones/policies table width + matrix_width = len(top_border) + target_width = PadFirewall.table_width() + padding = max(0, (target_width - matrix_width) // 2) + indent = " " * padding + + # Center the title underline to match table width + title_padding = max(0, (target_width - len("Zone Matrix")) // 2) + title_underline = "─" * target_width + + print(title_underline) + print(f"{'':<{title_padding}}{Decore.bold('Zone Matrix')}") + print(f"{indent}{top_border}") + print(f"{indent}{hdr}") + print(f"{indent}{middle_border}") + + for from_zone in zone_names: + row = f"│ {from_zone:>{left_col_width}} │" + for to_zone in zone_names: + symbol = traffic_symbol(from_zone, to_zone, policy_map, zones) + row += f" {symbol:^{col_width}} │" + print(f"{indent}{row}") + print(f"{indent}{bottom_border}") + + # Center the legend + legend = "✓ Allow ✗ Deny ⚠ Conditional" + legend_padding = max(0, (target_width - len(legend)) // 2) + print(f"{'':<{legend_padding}}{legend}") + + +def show_firewall_zone(json, zone_name=None): + """Show firewall zones table or specific zone details""" + fw = json.get('infix-firewall:firewall', {}) + zones = fw.get('zone', []) + policies = fw.get('policy', []) + + if zone_name: + zone = next((z for z in zones if z.get('name') == zone_name), None) + if not zone: + print(f"Zone '{zone_name}' not found") + return + + description = zone.get('description', '') + interfaces = zone.get('interface', []) + if not interfaces: + interfaces = "" + sources = zone.get('source', []) + if not sources: + sources = "" + services = zone.get('service', []) + if not services: + services = "" + policy = zone.get('policy', 'accept') + if policy == 'accept': + services_display = "(any)" + else: + services_display = ", ".join(services) if services else "(none)" + forwarding = "yes" if zone.get('forwarding') else "no" + + print(format_description('description', description)) + print(f"{'name':<20}: {zone_name}") + print(f"{'policy':<20}: {policy}") + print(f"{'interfaces':<20}: {', '.join(interfaces)}") + print(f"{'sources':<20}: {', '.join(sources)}") + print(f"{'forwarding':<20}: {forwarding}") + print(f"{'services':<20}: {services_display}") + + port_forwards = zone.get('port-forward', []) + if port_forwards: + hdr = (f"{'FROM':<{PadFirewall.zone_pfwd_from}}" + f"{'TO':<{PadFirewall.zone_pfwd_to}}") + Decore.title("Port Forwards", len(hdr)) + print(Decore.invert(hdr)) + + for fwd in port_forwards: + lower = fwd.get('port', {}) + proto = fwd.get('proto', {}) + from_port = f"{lower}/{proto}" + + to = fwd.get('to', {}) + to_str = f"{to.get('addr', '')}:{to.get('port', lower)}" + + print(f"{from_port:<{PadFirewall.zone_pfwd_from}}" + f"{to_str:<{PadFirewall.zone_pfwd_to}}") + + hdr = (f"{'TO ZONE':<{PadFirewall.zone_flow_to}}" + f"{'ACTION':<{PadFirewall.zone_flow_action}}" + f"{'POLICY':<{PadFirewall.zone_flow_policy}}") + Decore.title("Traffic Flows", len(hdr)) + print(Decore.invert(hdr)) + + for other_zone in zones: + if other_zone.get('name') == zone_name: + continue + + # Check if there's a policy allowing this flow + other_name = other_zone.get('name') + policy_name = "(none)" + action = "✗ deny" + for policy in policies: + if zone_name in policy.get('ingress', []) and other_name \ + in policy.get('egress', []): + policy_name = policy.get('name', 'unknown') + action = "✓ allow" + break + print(f"{other_name:<{PadFirewall.zone_flow_to}}" + f"{action:<{PadFirewall.zone_flow_action}}" + f"{policy_name}") + else: + hdr = (f"{'NAME':<{PadFirewall.zone_name}}" + f"{'ACTION':<{PadFirewall.zone_action}}" + f"{'INTERFACES':<{PadFirewall.zone_interfaces}}" + f"{'SERVICES':<{PadFirewall.zone_services}}") + Decore.title("Zones", len(hdr)) + print(Decore.invert(hdr)) + for zone in zones: + name = zone.get('name', '') + action = zone.get('policy', 'accept') + interfaces = ", ".join(zone.get('interface', [])) + if not interfaces: + interfaces = "(none)" + services = ", ".join(zone.get('service', [])) + if not services: + if action == "accept": + services = "(any)" + else: + services = "(none)" + + print(f"{name:<{PadFirewall.zone_name}}" + f"{action:<{PadFirewall.zone_action}}" + f"{interfaces:<{PadFirewall.zone_interfaces}}" + f"{services}") + + +def show_firewall_policy(json, policy_name=None): + """Show firewall policies table or specific policy details""" + fw = json.get('infix-firewall:firewall', {}) + policies = fw.get('policy', []) + + if policy_name: + policy = next((p for p in policies if p.get('name') == policy_name), None) + if not policy: + print(f"Policy '{policy_name}' not found") + return + + ingress = policy.get('ingress', []) + egress = policy.get('egress', []) + policy_action = policy.get('policy', 'reject') + masquerade = "yes" if policy.get('masquerade') else "no" + description = policy.get('description', '') + services = policy.get('service', []) + if policy == 'accept': + services_display = "(all)" + else: + services_display = ", ".join(services) if services else "(none)" + + print(format_description('description', description)) + print(f"{'name':<20}: {policy_name}") + print(f"{'ingress':<20}: {', '.join(ingress) if ingress else '(none)'}") + print(f"{'egress':<20}: {', '.join(egress) if egress else '(none)'}") + print(f"{'policy':<20}: {policy_action}") + print(f"{'masquerade':<20}: {masquerade}") + print(f"{'services':<20}: {services_display}") + else: + hdr = (f"{'NAME':<{PadFirewall.policy_name}}" + f"{'ACTION':<{PadFirewall.policy_action}}" + f"{'INGRESS':<{PadFirewall.policy_ingress}}" + f"{'EGRESS':<{PadFirewall.policy_egress}}") + Decore.title("Policies", len(hdr)) + print(Decore.invert(hdr)) + for policy in policies: + name = policy.get('name', '') + ingress = ", ".join(policy.get('ingress', [])) + egress = ", ".join(policy.get('egress', [])) + action = policy.get('policy', 'reject') + + print(f"{name:<{PadFirewall.policy_name}}" + f"{action:<{PadFirewall.policy_action}}" + f"{ingress:<{PadFirewall.policy_ingress}}" + f"{egress:<{PadFirewall.policy_egress}}") + + +def format_port_list(ports): + """Format port list from YANG data""" + if not ports: + return "(none)" + + formatted = [] + for port in ports: + proto = port.get('proto', 'tcp') + lower = port.get('lower') + upper = port.get('upper') + + if upper and upper != lower: + formatted.append(f"{lower}-{upper}/{proto}") + else: + formatted.append(f"{lower}/{proto}") + + return ", ".join(formatted) + + +def show_firewall_service(json, name=None): + """Show firewall services table or specific service details""" + fw = json.get('infix-firewall:firewall', {}) + services = fw.get('service', []) + + if name: + service = next((s for s in services if s.get('name') == name), None) + if not service: + print(f"Service '{name}' not found") + return + + ports = format_port_list(service.get('port', [])) + description = service.get('description', '') + + print(f"{'name':<20}: {name}") + print(f"{'ports':<20}: {ports}") + print(format_description('description', description)) + else: + hdr = (f"{'NAME':<{PadFirewall.service_name}}" + f"{'PORTS':<{PadFirewall.service_ports}}") + print(Decore.invert(hdr)) + for service in services: + name = service.get('name', '') + ports = format_port_list(service.get('port', [])) + + print(f"{name:<{PadFirewall.service_name}}" + f"{ports:<{PadFirewall.service_ports}}") + + def main(): global UNIT_TEST @@ -1449,6 +1858,13 @@ def main(): .add_argument('-n', '--name', help='Interface name') subparsers.add_parser('show-lldp', help='Show LLDP neighbors') + subparsers.add_parser('show-firewall', help='Show firewall overview') + subparsers.add_parser('show-firewall-zone', help='Show firewall zones') \ + .add_argument('name', nargs='?', help='Zone name') + subparsers.add_parser('show-firewall-policy', help='Show firewall policies') \ + .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-ntp', help='Show NTP sources') @@ -1473,6 +1889,14 @@ def main(): show_interfaces(json_data, args.name) elif args.command == "show-lldp": show_lldp(json_data) + elif args.command == "show-firewall": + show_firewall(json_data) + elif args.command == "show-firewall-zone": + show_firewall_zone(json_data, args.name) + elif args.command == "show-firewall-policy": + show_firewall_policy(json_data, args.name) + elif args.command == "show-firewall-service": + show_firewall_service(json_data, args.name) elif args.command == "show-ntp": show_ntp(json_data) elif args.command == "show-routing-table": diff --git a/src/statd/python/yanger/infix_firewall.py b/src/statd/python/yanger/infix_firewall.py index 9c6783a28..8f34361cb 100644 --- a/src/statd/python/yanger/infix_firewall.py +++ b/src/statd/python/yanger/infix_firewall.py @@ -10,7 +10,7 @@ from . import common -def get_interface(interface = "org.fedoraproject.FirewallD1"): +def get_interface(interface="org.fedoraproject.FirewallD1"): try: bus = dbus.SystemBus() obj = bus.get_object("org.fedoraproject.FirewallD1", @@ -23,9 +23,15 @@ def get_interface(interface = "org.fedoraproject.FirewallD1"): def get_zone_data(fw, name): + """ + $ gdbus call --system --dest org.fedoraproject.FirewallD1 \ + --object-path /org/fedoraproject/FirewallD1 \ + --method org.fedoraproject.FirewallD1.zone.getForwardPorts \ + external + ([['443', 'tcp', '443', '192.168.2.10']],) + """ try: settings = fw.getZoneSettings2(name) - zone = { "name": name, "policy": "accept", @@ -45,36 +51,42 @@ def get_zone_data(fw, name): "default": "accept" } zone["policy"] = policy.get(target, "accept") + zone["forwarding"] = bool(settings.get('forward', 0)) + zone["description"] = settings.get('description', 0) forwards = settings.get('forward_ports', []) for fwd in forwards: - fwd_data = {} - port_data = {} - - if len(fwd) >= 4: - port, protocol, toaddr, toport = fwd[:4] - - if '-' in str(port): - lower, upper = str(port).split('-', 1) - port_data['lower'] = int(lower) - port_data['upper'] = int(upper) - else: - port_data['lower'] = int(port) - port_data['proto'] = protocol - - fwd_data['port'] = port_data - fwd_data['to'] = {'addr': toaddr} - - if '-' in str(toport): - lower, upper = str(toport).split('-', 1) - fwd_data['to']['lower'] = int(lower) - fwd_data['to']['upper'] = int(upper) - else: - fwd_data['to']['lower'] = int(toport) - - zone["port-forward"].append(fwd_data) - - zone["forwarding"] = bool(settings.get('forward', 0)) + try: + if len(fwd) >= 4: + port, protocol, toport, toaddr = fwd[:4] # Fixed field order! + + # TODO: extend YANG model with lower/upper + fwd_data = { + 'port': int(port), # TODO: Single port number now. + 'proto': str(protocol), + 'to': { + 'addr': str(toaddr) + } + } + + # Handle destination port - might be empty or IP + if toport and str(toport).strip(): + toport_str = str(toport).strip() + # Skip if toport looks like an IP address instead of port + if '.' not in toport_str and ':' not in toport_str: + fwd_data['to']['port'] = int(toport_str) + else: + # If toport looks like IP, use the same port as source + fwd_data['to']['port'] = int(port) + else: + # No destination port specified, use same as source + fwd_data['to']['port'] = int(port) + + zone["port-forward"].append(fwd_data) + + except (ValueError, TypeError) as e: + common.LOG.warning("Skipping invalid port forward rule in zone %s: %s (data: %s)", + name, e, fwd) return zone except Exception as e: @@ -83,15 +95,20 @@ def get_zone_data(fw, name): def get_zones(fw): + """Get only active zones (loaded in kernel) instead of all zones""" zones = [] try: - fw = get_interface("org.fedoraproject.FirewallD1.zone") - if not fw: + fwz = get_interface("org.fedoraproject.FirewallD1.zone") + if not fwz: return zones - for name in fw.getZones(): - zone_data = get_zone_data(fw, name) + active_zones = fwz.getActiveZones() + for name, zone_info in active_zones.items(): + zone_data = get_zone_data(fwz, name) if zone_data: + # Override interfaces and sources with active zone data to ensure accuracy + zone_data['interface'] = list(zone_info.get('interfaces', [])) + zone_data['source'] = list(zone_info.get('sources', [])) zones.append(zone_data) except Exception as e: @@ -133,7 +150,7 @@ def get_policy_data(fw, name): if ingress: policy["ingress"] = list(ingress) - egress = settings.get('egress', []) + egress = settings.get('egress_zones', []) if egress: policy["egress"] = list(egress) @@ -153,12 +170,12 @@ def get_policy_data(fw, name): def get_policies(fw): policies = [] try: - fw = get_interface("org.fedoraproject.FirewallD1.policy") - if not fw: + fwp = get_interface("org.fedoraproject.FirewallD1.policy") + if not fwp: return policies - for name in fw.getPolicies(): - data = get_policy_data(fw, name) + for name in fwp.getPolicies(): + data = get_policy_data(fwp, name) if data: policies.append(data) diff --git a/test/case/statd/system/cli/show-hardware b/test/case/statd/system/cli/show-hardware index a598d096e..10645d553 100644 --- a/test/case/statd/system/cli/show-hardware +++ b/test/case/statd/system/cli/show-hardware @@ -1,4 +1,5 @@ -USB PORTS  +────────────────────────────── +USB PORTS NAME STATE  USB locked USB2 locked From 383b58b8b8f8ff139f482d1f152f0c3b206e7cf9 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 17 Aug 2025 15:16:29 +0200 Subject: [PATCH 22/55] confd: simplify Signed-off-by: Joachim Wiberg --- src/confd/src/infix-firewall.c | 66 ++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 30 deletions(-) diff --git a/src/confd/src/infix-firewall.c b/src/confd/src/infix-firewall.c index 4216f94c6..921c1dbb1 100644 --- a/src/confd/src/infix-firewall.c +++ b/src/confd/src/infix-firewall.c @@ -110,7 +110,7 @@ static int delete_file(const char *dir, const char *name) return SR_ERR_OK; } -static int generate_zone_with_extra_interfaces(const char *name, struct lyd_node *cfg, char **extra_ifaces) +static int do_generate_zone(const char *name, struct lyd_node *cfg, char **ifaces) { const char *policy, *desc; struct lyd_node *node; @@ -131,10 +131,10 @@ static int generate_zone_with_extra_interfaces(const char *name, struct lyd_node LYX_LIST_FOR_EACH(lyd_child(cfg), node, "interface") fprintf(fp, " \n", lyd_get_value(node)); - if (extra_ifaces) { - for (int i = 0; extra_ifaces[i]; i++) { - if (extra_ifaces[i][0] != '\0') { - fprintf(fp, " \n", extra_ifaces[i]); + if (ifaces) { + for (int i = 0; ifaces[i]; i++) { + if (ifaces[i][0] != '\0') { + fprintf(fp, " \n", ifaces[i]); } } } @@ -186,39 +186,45 @@ static int generate_zone_with_extra_interfaces(const char *name, struct lyd_node static int generate_zone(const char *name, struct lyd_node *cfg) { - return generate_zone_with_extra_interfaces(name, cfg, NULL); + return do_generate_zone(name, cfg, NULL); } -static int generate_default_zone_with_remaining_interfaces(struct lyd_node *tree, char **l3_ifaces) +static int generate_default_zone(const char *name, struct lyd_node *tree, char **ifaces) { - const char *default_zone = lydx_get_cattr(lydx_get_descendant(tree, "firewall", NULL), "default"); - struct lyd_node *zones, *zone_cfg = NULL; - int unassigned_count = 0; - - for (int i = 0; l3_ifaces[i]; i++) { - if (l3_ifaces[i][0] != '\0') { - unassigned_count++; - } + struct lyd_node *zones, *cfg = NULL; + size_t num = 0; + + for (int i = 0; ifaces && ifaces[i]; i++) { + if (ifaces[i][0] != '\0') + num++; } - - if (unassigned_count == 0) - return 0; - + zones = lydx_get_descendant(tree, "firewall", "zone", NULL); - LYX_LIST_FOR_EACH(zones, zone_cfg, "zone") { - const char *name = lydx_get_cattr(zone_cfg, "name"); - if (!strcmp(name, default_zone)) + LYX_LIST_FOR_EACH(zones, cfg, "zone") { + if (!strcmp(name, lydx_get_cattr(cfg, "name"))) break; } - - ERROR("Adding %d unassigned interfaces to default zone '%s':", unassigned_count, default_zone); - for (int i = 0; l3_ifaces[i]; i++) { - if (l3_ifaces[i][0] != '\0') { - ERROR(" - %s", l3_ifaces[i]); + + if (num > 0) { + size_t sz = num * 16 + 2 * num + 1; + char buf[sz]; + int hit = 0; + + memset(buf, 0, sz); + for (int i = 0; ifaces[i]; i++) { + if (ifaces[i][0] == '\0') + continue; + if (hit) + strlcat(buf, ", ", sz); + strlcat(buf, ifaces[i], sz); + hit++; } + + WARN("Adding %zu unassigned interfaces to default zone '%s': %s", + num, name, buf); } - return generate_zone_with_extra_interfaces(default_zone, zone_cfg, l3_ifaces); + return do_generate_zone(name, cfg, ifaces); } static int generate_service(const char *name, struct lyd_node *service_cfg) @@ -418,7 +424,7 @@ static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module list = lydx_get_descendant(diff, "firewall", "zone", NULL); LYX_LIST_FOR_EACH(list, node, "zone") { const char *name = lydx_get_cattr(node, "name"); - + if (lydx_get_op(node) == LYDX_OP_DELETE) { delete_file(FIREWALLD_ZONES_DIR, name); reload_needed = true; @@ -446,7 +452,7 @@ static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module } /* Stage 2: Generate default zone with remaining interfaces */ - if (l3_ifaces && generate_default_zone_with_remaining_interfaces(tree, l3_ifaces) == 0) + if (l3_ifaces && generate_default_zone(default_zone, tree, l3_ifaces) == 0) reload_needed = true; list = lydx_get_descendant(diff, "firewall", "service", NULL); From 709eef9172277ca3345c7b8af59d8361ee047570 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 17 Aug 2025 15:16:38 +0200 Subject: [PATCH 23/55] board/common: stop-start firewalld for now, --reload takes too long Signed-off-by: Joachim Wiberg --- board/common/rootfs/etc/finit.d/available/firewalld.conf | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/board/common/rootfs/etc/finit.d/available/firewalld.conf b/board/common/rootfs/etc/finit.d/available/firewalld.conf index 0b7597a3b..ec6804e6a 100644 --- a/board/common/rootfs/etc/finit.d/available/firewalld.conf +++ b/board/common/rootfs/etc/finit.d/available/firewalld.conf @@ -1,3 +1,4 @@ -service [2345] reload:'firewall-cmd --reload' \ +# reload:'firewall-cmd --reload' <-- Takes too long? +service [2345] \ firewalld --nofork --log-target syslog \ -- Firewall daemon From a95287e0aff9242790781aeb29d34569bfc71d78 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 17 Aug 2025 18:21:41 +0200 Subject: [PATCH 24/55] cli: firewall: fix lag in tab completion and colorize zone matrix Signed-off-by: Joachim Wiberg --- src/klish-plugin-infix/src/infix.c | 46 ++++++++++++++++---- src/statd/python/cli_pretty/cli_pretty.py | 52 +++++++++++++++++------ 2 files changed, 77 insertions(+), 21 deletions(-) diff --git a/src/klish-plugin-infix/src/infix.c b/src/klish-plugin-infix/src/infix.c index 0073afaeb..0232e3d87 100644 --- a/src/klish-plugin-infix/src/infix.c +++ b/src/klish-plugin-infix/src/infix.c @@ -142,31 +142,59 @@ int infix_ifaces(kcontext_t *ctx) return 0; } -static int firewall_completion(const char *type) +static int firewall_dbus_completion(const char *interface, const char *method, const char *parser) { - const char *cmd = "sysrepocfg -X -d operational -f json -x"; - - return systemf("%s /infix-firewall:firewall/%s/name 2>/dev/null" - " | jq -r '.\"infix-firewall:firewall\".%s[]?.name // empty'" - " 2>/dev/null", cmd, type, type); + return systemf("gdbus call --system --dest org.fedoraproject.FirewallD1 " + "--object-path /org/fedoraproject/FirewallD1 " + "--method org.fedoraproject.FirewallD1.%s.%s 2>/dev/null " + "| %s", interface, method, parser); } +/* + * Completion function for firewall zones. + * D-Bus returns variant format: ({'zone1': {...}},) + * Pipeline: + * - sed removes wrapper parentheses + * - tr converts single to double quotes + * - jq extracts keys + */ int infix_firewall_zones(kcontext_t *ctx) { (void)ctx; - return firewall_completion("zone"); + return firewall_dbus_completion("zone", "getActiveZones", + "sed 's/^(//; s/,)$//' | tr \"'\" '\"' | jq -r 'keys[]' 2>/dev/null"); } +/* + * Completion function for firewall policies. + * D-Bus returns variant format: (['policy1', 'policy2'],) + * Pipeline: + * - sed removes wrapper parentheses + * - tr converts single to double quotes + * - jq extracts array items + */ int infix_firewall_policies(kcontext_t *ctx) { (void)ctx; - return firewall_completion("policy"); + return firewall_dbus_completion("policy", "getPolicies", + "sed 's/^(//; s/,)$//' | tr \"'\" '\"' | jq -r '.[]' 2>/dev/null"); } +/* + * Completion function for firewall services. + * D-Bus returns variant format: (['dhcp', 'dns', 'ssh'],) + * Pipeline: + * - sed removes wrapper parentheses + * - tr converts single to double quotes + * - jq extracts array items + */ int infix_firewall_services(kcontext_t *ctx) { (void)ctx; - return firewall_completion("service"); + return systemf("gdbus call --system --dest org.fedoraproject.FirewallD1 " + "--object-path /org/fedoraproject/FirewallD1 " + "--method org.fedoraproject.FirewallD1.listServices 2>/dev/null " + "| sed 's/^(//; s/,)$//' | tr \"'\" '\"' | jq -r '.[]' 2>/dev/null"); } int infix_copy(kcontext_t *ctx) diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index ed6f2dc2c..25adaddfb 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -162,6 +162,18 @@ def underline(txt): def gray_bg(txt): return Decore.decorate("100", txt) + @staticmethod + def red_bg(txt): + return Decore.decorate("41", txt, "49") + + @staticmethod + def green_bg(txt): + return Decore.decorate("42", txt, "49") + + @staticmethod + def yellow_bg(txt): + return Decore.decorate("43", txt, "49") + @staticmethod def title(txt, len=None, bold=True): """Print section header with horizontal bar line above it @@ -1530,10 +1542,14 @@ def build_policy_map(policies): return policy_map -def traffic_symbol(from_zone, to_zone, policy_map, zones): +def traffic_symbol(from_zone, to_zone, policy_map, zones, cell_width): """Determine the symbol to show for zone-to-zone traffic""" + def make_cell(symbol, bg_func): + # Create full-width colored cell + return bg_func(f" {symbol:^{cell_width}} ") + if from_zone == to_zone: - return "✓" + return make_cell("✓", Decore.green_bg) key = (from_zone, to_zone) policy = policy_map.get(key) @@ -1545,14 +1561,15 @@ def traffic_symbol(from_zone, to_zone, policy_map, zones): action = zone.get('policy', 'accept') pfwd = zone.get('port-forward', []) if action in ['reject', 'drop'] and pfwd: - return "⚠" # Some traffic allowed via port forwarding + # Some traffic allowed via port forwarding + return make_cell("⚠", Decore.yellow_bg) - return "✗" + return make_cell("✗", Decore.red_bg) if policy['conditional']: - return "⚠" + return make_cell("⚠", Decore.yellow_bg) - return "✓" + return make_cell("✓", Decore.green_bg) def show_firewall_matrix(fw): @@ -1615,15 +1632,26 @@ def show_firewall_matrix(fw): for from_zone in zone_names: row = f"│ {from_zone:>{left_col_width}} │" for to_zone in zone_names: - symbol = traffic_symbol(from_zone, to_zone, policy_map, zones) - row += f" {symbol:^{col_width}} │" + symbol = traffic_symbol(from_zone, to_zone, policy_map, zones, col_width) + row += f"{symbol}│" print(f"{indent}{row}") print(f"{indent}{bottom_border}") - # Center the legend - legend = "✓ Allow ✗ Deny ⚠ Conditional" - legend_padding = max(0, (target_width - len(legend)) // 2) - print(f"{'':<{legend_padding}}{legend}") + # Center the legend - define parts first for length calculation + legend_data = [ + ("✓ Allow", Decore.green_bg), + ("✗ Deny", Decore.red_bg), + ("⚠ Conditional", Decore.yellow_bg) + ] + + # Calculate visible length, then colorize the parts + visible_parts = [f" {text} " for text, _ in legend_data] + visible_legend = " ".join(visible_parts) + colorized_parts = [bg_func(f" {text} ") for text, bg_func in legend_data] + legend = " ".join(colorized_parts) + # Depending on taste and number of zones, but +1 works for me + legend_padding = max(0, (target_width - len(visible_legend)) // 2) + 1 + print(f"{' ' * legend_padding}{legend}") def show_firewall_zone(json, zone_name=None): From aedd387869761da87c0856a815ed7d0c482ce60d Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 17 Aug 2025 19:51:21 +0200 Subject: [PATCH 25/55] confd: replace 'firewall-cmd --reload' with a little script Not really, just fix the reload functionality so we don't block forever in Finit. The D-Bus API is *much* quicker and less buggy that the old firewall-cmd command. Signed-off-by: Joachim Wiberg --- .../etc/finit.d/available/firewalld.conf | 5 +- src/confd/bin/Makefile.am | 2 +- src/confd/bin/firewall | 386 ++++++++++++++++++ 3 files changed, 389 insertions(+), 4 deletions(-) create mode 100755 src/confd/bin/firewall diff --git a/board/common/rootfs/etc/finit.d/available/firewalld.conf b/board/common/rootfs/etc/finit.d/available/firewalld.conf index ec6804e6a..a39a8d13a 100644 --- a/board/common/rootfs/etc/finit.d/available/firewalld.conf +++ b/board/common/rootfs/etc/finit.d/available/firewalld.conf @@ -1,4 +1,3 @@ -# reload:'firewall-cmd --reload' <-- Takes too long? -service [2345] \ - firewalld --nofork --log-target syslog \ +service [2345] reload:'firewall reload' \ + firewalld --nofork --log-target syslog \ -- Firewall daemon diff --git a/src/confd/bin/Makefile.am b/src/confd/bin/Makefile.am index 925e5130c..49bed009d 100644 --- a/src/confd/bin/Makefile.am +++ b/src/confd/bin/Makefile.am @@ -1,4 +1,4 @@ pkglibexec_SCRIPTS = bootstrap error load gen-service gen-hostname \ gen-interfaces gen-motd gen-hardware gen-version \ mstpd-wait-online wait-interface -sbin_SCRIPTS = dagger migrate +sbin_SCRIPTS = dagger migrate firewall diff --git a/src/confd/bin/firewall b/src/confd/bin/firewall new file mode 100755 index 000000000..7958b7eb4 --- /dev/null +++ b/src/confd/bin/firewall @@ -0,0 +1,386 @@ +#!/bin/sh +# Firewall debug and management utility using D-Bus API +# +# SPDX-License-Identifier: BSD-3-Clause + +DEST="org.fedoraproject.FirewallD1" +OBJECT="/org/fedoraproject/FirewallD1" +INTERFACE="org.fedoraproject.FirewallD1" + +check_firewalld() +{ + gdbus call --system --dest "$DEST" --object-path "$OBJECT" \ + --method "$INTERFACE.getDefaultZone" >/dev/null 2>&1 +} + +call_reload() +{ + output=$(gdbus call --system --dest "$DEST" --object-path "$OBJECT" \ + --method "$INTERFACE.reload" 2>&1) + ret=$? + + # Validate both return code and output + if [ $ret -eq 0 ] && [ "$output" = "()" ]; then + return 0 + else + echo "Error: Reload method failed (exit code: $ret, output: '$output')" >&2 + return 1 + fi +} + +wait_for_reload() +{ + timeout_val=$1 + + timeout "$timeout_val" gdbus monitor --system --dest "$DEST" \ + --object-path "$OBJECT" 2>/dev/null | \ + while IFS= read line; do + if echo "$line" | grep -q "Reloaded"; then + return 0 + fi + done + + echo "Timeout waiting for firewall reload completion" >&2 + return 1 +} + +gdbus_call() +{ + method=$1 + result=$(gdbus call --system --dest "$DEST" --object-path "$OBJECT" \ + --method "$INTERFACE.$method" 2>/dev/null | \ + sed 's/^(//; s/,)$//; s/[(),]//g' | tr -d ' ') + + # Check if call succeeded (non-empty result indicates success) + if [ -n "$result" ]; then + echo "$result" + return 0 + else + return 1 + fi +} + +is_panic_enabled() +{ + result=$(gdbus_call "queryPanicMode") + if [ $? -eq 0 ] && [ "$result" = "true" ]; then + return 0 + fi + + return 1 +} + +panic_on() +{ + is_panic_enabled && return 0 + + if ! gdbus call --system --dest "$DEST" --object-path "$OBJECT" \ + --method "$INTERFACE.enablePanicMode" >/dev/null 2>&1; then + echo "Error: Failed to activate lockdown mode" >&2 + return 1 + fi + + logger -p user.emerg "LOCKDOWN MODE ACTIVATED - All network traffic blocked" +} + +panic_off() +{ + is_panic_enabled || return 0 + + if ! gdbus call --system --dest "$DEST" --object-path "$OBJECT" \ + --method "$INTERFACE.disablePanicMode" >/dev/null 2>&1; then + echo "Error: Failed to deactivate lockdown mode" >&2 + return 1 + fi + + logger -p user.emerg "LOCKDOWN MODE DEACTIVATED - Normal network operation restored" +} + +panic_status() +{ + if is_panic_enabled; then + echo "Lockdown mode: ACTIVE" + else + echo "Lockdown mode: INACTIVE" + fi + return 0 +} + +show_status() +{ + echo "=== Firewall Status ===" + + # Basic status + echo "Service Status:" + if check_firewalld; then + echo " Firewalld: RUNNING" + else + echo " Firewalld: NOT RUNNING" + return 1 + fi + + # Query lockdown status using helper + if is_panic_enabled; then + lockdown_upper="TRUE" + else + lockdown_upper="FALSE" + fi + echo " Lockdown Mode: $lockdown_upper" + + # Default zone and logging using generic helper + default_zone=$(gdbus_call "getDefaultZone" | sed "s/[']//g") + logging=$(gdbus_call "getLogDenied" | sed "s/[']//g") + + echo " Default Zone: $default_zone" + echo " Log Denied: $logging" + echo + + # Active zones + echo "=== Active Zones ===" + zones_output=$(gdbus call --system --dest "$DEST" --object-path "$OBJECT" \ + --method org.fedoraproject.FirewallD1.zone.getActiveZones 2>/dev/null | \ + sed 's/^(//; s/,)$//' | tr "'" '"') + + if echo "$zones_output" | jq -e . >/dev/null 2>&1; then + echo "$zones_output" | jq -r 'to_entries[] | " \(.key): \(.value.interfaces // [] | join(", "))"' 2>/dev/null || echo " Failed to parse zones" + else + echo " No zones or failed to retrieve" + fi + echo + + # Services + echo "=== Available Services ===" + services=$(gdbus call --system --dest "$DEST" --object-path "$OBJECT" \ + --method "$INTERFACE.listServices" 2>/dev/null | \ + sed 's/^(//; s/,)$//' | tr "'" '"') + + if echo "$services" | jq -e . >/dev/null 2>&1; then + echo "$services" | jq -r '.[] | " " + .' 2>/dev/null | head -20 + count=$(echo "$services" | jq -r '. | length' 2>/dev/null) + if [ "$count" -gt 20 ]; then + echo " ... and $((count - 20)) more" + fi + else + echo " Failed to retrieve services" + fi + echo + + # Policies + echo "=== Policies ===" + policies=$(gdbus call --system --dest "$DEST" --object-path "$OBJECT" \ + --method org.fedoraproject.FirewallD1.policy.getPolicies 2>/dev/null | \ + sed 's/^(//; s/,)$//' | tr "'" '"') + + if echo "$policies" | jq -e . >/dev/null 2>&1; then + policy_count=$(echo "$policies" | jq -r '. | length' 2>/dev/null) + echo " Total policies: $policy_count" + + if [ "$policy_count" -gt 0 ]; then + echo "$policies" | jq -r '.[]' 2>/dev/null | while read policy_name; do + echo " Policy: $policy_name" + + # Get policy details + policy_settings=$(gdbus call --system --dest "$DEST" --object-path "$OBJECT" \ + --method org.fedoraproject.FirewallD1.policy.getPolicySettings \ + "$policy_name" 2>/dev/null) + + if [ -n "$policy_settings" ] && [ "${policy_settings#*Error}" = "$policy_settings" ]; then + # Parse D-Bus variant format + + # Extract target + target=$(echo "$policy_settings" | grep -o "'target': <'[^']*'" | cut -d"'" -f4) + + # Extract description + description=$(echo "$policy_settings" | grep -o "'description': <'[^']*'" | cut -d"'" -f4) + + # Extract masquerade + masquerade=$(echo "$policy_settings" | grep -o "'masquerade': <[^>]*>" | sed "s/.*<\([^>]*\)>.*/\1/") + + # Extract priority + priority=$(echo "$policy_settings" | grep -o "'priority': <[^>]*>" | sed "s/.*<\([^>]*\)>.*/\1/") + + # Extract ingress zones - handle the specific format from your example + if echo "$policy_settings" | grep -q "'ingress_zones'"; then + # Match: 'ingress_zones': <['internal']> or 'ingress_zones': <['dmz', 'internal']> + ingress_zones=$(echo "$policy_settings" | grep -o "'ingress_zones': <\[[^]]*\]>" | sed "s/'ingress_zones': <\[//; s/\]>//; s/'//g" | sed 's/, */, /g') + fi + + # Extract egress zones - handle the specific format + if echo "$policy_settings" | grep -q "'egress_zones'"; then + # Match: 'egress_zones': <['external']> or 'egress_zones': <['HOST']> + egress_zones=$(echo "$policy_settings" | grep -o "'egress_zones': <\[[^]]*\]>" | sed "s/'egress_zones': <\[//; s/\]>//; s/'//g" | sed 's/, */, /g') + fi + + # Extract rich rules - handle the complex nested quotes carefully + if echo "$policy_settings" | grep -q "'rich_rules'"; then + # Extract the rich rules array content + rich_rules=$(echo "$policy_settings" | grep -o "'rich_rules': <\[[^]]*\]>" | sed "s/'rich_rules': <\[//; s/\]>//") + fi + + echo " Target: ${target:-unknown}" + echo " Description: ${description:-none}" + echo " Priority: ${priority:-unknown}" + echo " Ingress: ${ingress_zones:-none}" + echo " Egress: ${egress_zones:-none}" + echo " Masquerade: ${masquerade:-false}" + + # Parse and display rich rules + if [ -n "$rich_rules" ] && [ "$rich_rules" != "" ]; then + rule_count=$(echo "$rich_rules" | grep -o "'" | wc -l) + rule_count=$((rule_count / 2)) + + if [ "$rule_count" -gt 0 ]; then + echo " Rich Rules ($rule_count):" + # Extract individual rules + echo "$rich_rules" | grep -o "'[^']*'" | sed "s/'//g" | while read rule; do + echo " $rule" + done + else + echo " Rich Rules: none" + fi + else + echo " Rich Rules: none" + fi + else + echo " (Failed to get policy details)" + fi + echo + done + fi + else + echo " No policies or failed to retrieve" + fi + + # Runtime info + echo "=== Runtime Information ===" + echo " nftables rules:" + rule_count=$(nft list ruleset 2>/dev/null | grep -c "^[[:space:]]*[^#]" || echo "0") + echo " Active rules: $rule_count" + + table_count=$(nft list tables 2>/dev/null | wc -l || echo "0") + echo " Active tables: $table_count" +} + +# Function to show usage +show_help() +{ + cat << EOF +Usage: $0 [OPTIONS] COMMAND + +OPTIONS: + --wait SEC Wait for reload completion signal (use with reload command) + -h, --help Show this help message + +COMMANDS: + reload Reload firewall configuration + panic OPERATION Emergency panic mode: + show Show comprehensive firewall status and configuration + help Show this help message + +EXAMPLES: + $0 reload Reload firewall (returns immediately) + $0 --wait 30 reload Reload firewall and wait up to 30s for completion + $0 panic on Enable panic mode (blocks ALL traffic) + $0 panic off Disable panic mode + $0 panic status Query current panic status + $0 show Display complete firewall status + +This tool uses the FirewallD D-Bus API directly for reliable operation. +EOF +} + +# Main logic +main() +{ + wait_timeout="" + + # Parse options using getopt + if ! parsed_args=$(getopt -o h --long wait:,help -- "$@"); then + echo "Error parsing options" >&2 + exit 1 + fi + + eval set -- "$parsed_args" + + # Process options + while true; do + case "$1" in + --wait) + wait_timeout="$2" + shift 2 + ;; + -h|--help) + show_help + exit 0 + ;; + --) + shift + break + ;; + *) + echo "Error: Unknown option '$1'" >&2 + exit 1 + ;; + esac + done + + # Process command + case "${1:-}" in + reload) + if ! check_firewalld; then + echo "Error: firewalld is not running or not accessible" >&2 + exit 1 + fi + + # Call reload first + if ! call_reload; then + exit 1 + fi + + # Wait if --wait option was provided + if [ -n "$wait_timeout" ]; then + if ! wait_for_reload "$wait_timeout"; then + echo "Firewall reload timed out" >&2 + exit 1 + fi + fi + # Success - silent on UNIX principle + ;; + panic) + if ! check_firewalld; then + echo "Error: firewalld is not running or not accessible" >&2 + exit 1 + fi + + case "${2:-}" in + on) + panic_on + ;; + off) + panic_off + ;; + status) + panic_status + ;; + *) + echo "Error: Invalid panic operation '$2'" >&2 + echo "Use: $0 panic {on|off|status}" >&2 + exit 1 + ;; + esac + ;; + show) + show_status + ;; + help) + show_help + ;; + *) + echo "Error: Missing or unknown command '$1'" >&2 + echo "Use $0 help for usage information" + exit 1 + ;; + esac +} + +main "$@" From 254dd84dfc3a1781e381c8cec664bbe44fb3b4b3 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 17 Aug 2025 20:38:56 +0200 Subject: [PATCH 26/55] confd: simplify firewalld zone generation No need for all the complexity, firewalld handles the diffs anyway. Signed-off-by: Joachim Wiberg --- src/confd/src/infix-firewall.c | 214 ++++++++++++++------------------- 1 file changed, 93 insertions(+), 121 deletions(-) diff --git a/src/confd/src/infix-firewall.c b/src/confd/src/infix-firewall.c index 921c1dbb1..ca3797415 100644 --- a/src/confd/src/infix-firewall.c +++ b/src/confd/src/infix-firewall.c @@ -63,22 +63,50 @@ static const char *policy_action_to_target(const char *action) return policy_action_map[0].yang; } -static void mark_interfaces_used(struct lyd_node *cfg, char **l3_ifaces) +static void mark_interfaces_used(struct lyd_node *cfg, char **ifaces) { struct lyd_node *node; LYX_LIST_FOR_EACH(lyd_child(cfg), node, "interface") { const char *ifname = lyd_get_value(node); - for (int i = 0; l3_ifaces[i]; i++) { - if (!strcmp(l3_ifaces[i], ifname)) { - l3_ifaces[i][0] = '\0'; + for (int i = 0; ifaces[i]; i++) { + if (!strcmp(ifaces[i], ifname)) { + ifaces[i][0] = '\0'; break; } } } } +static void log_unzoned(const char *name, char **ifaces) +{ + size_t num = 0; + + for (int i = 0; ifaces && ifaces[i]; i++) { + if (ifaces[i][0] != '\0') + num++; + } + + if (num > 0) { + size_t sz = num * 16 + 2 * num + 1; + char buf[sz]; + int hit = 0; + + memset(buf, 0, sz); + for (int i = 0; ifaces[i]; i++) { + if (ifaces[i][0] == '\0') + continue; + if (hit) + strlcat(buf, ", ", sz); + strlcat(buf, ifaces[i], sz); + hit++; + } + + WARN("Adding %zu unassigned interfaces to default zone '%s': %s", + num, name, buf); + } +} static FILE *open_file(const char *dir, const char *name) { @@ -110,7 +138,7 @@ static int delete_file(const char *dir, const char *name) return SR_ERR_OK; } -static int do_generate_zone(const char *name, struct lyd_node *cfg, char **ifaces) +static int generate_zone(const char *name, struct lyd_node *cfg, char **ifaces) { const char *policy, *desc; struct lyd_node *node; @@ -137,6 +165,8 @@ static int do_generate_zone(const char *name, struct lyd_node *cfg, char **iface fprintf(fp, " \n", ifaces[i]); } } + + log_unzoned(name, ifaces); } LYX_LIST_FOR_EACH(lyd_child(cfg), node, "source") @@ -184,49 +214,6 @@ static int do_generate_zone(const char *name, struct lyd_node *cfg, char **iface return close_file(fp); } -static int generate_zone(const char *name, struct lyd_node *cfg) -{ - return do_generate_zone(name, cfg, NULL); -} - -static int generate_default_zone(const char *name, struct lyd_node *tree, char **ifaces) -{ - struct lyd_node *zones, *cfg = NULL; - size_t num = 0; - - for (int i = 0; ifaces && ifaces[i]; i++) { - if (ifaces[i][0] != '\0') - num++; - } - - zones = lydx_get_descendant(tree, "firewall", "zone", NULL); - LYX_LIST_FOR_EACH(zones, cfg, "zone") { - if (!strcmp(name, lydx_get_cattr(cfg, "name"))) - break; - } - - if (num > 0) { - size_t sz = num * 16 + 2 * num + 1; - char buf[sz]; - int hit = 0; - - memset(buf, 0, sz); - for (int i = 0; ifaces[i]; i++) { - if (ifaces[i][0] == '\0') - continue; - if (hit) - strlcat(buf, ", ", sz); - strlcat(buf, ifaces[i], sz); - hit++; - } - - WARN("Adding %zu unassigned interfaces to default zone '%s': %s", - num, name, buf); - } - - return do_generate_zone(name, cfg, ifaces); -} - static int generate_service(const char *name, struct lyd_node *service_cfg) { const char *desc; @@ -370,10 +357,11 @@ static int infer_zone(sr_session_ctx_t *session, const char *name, const char *d static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module, const char *xpath, sr_event_t event, unsigned request_id, void *_confd) { - struct lyd_node *diff, *tree, *list, *node, *global; + struct lyd_node *diff, *tree, *global; struct lyd_node *clist, *cnode; bool reload_needed = false; sr_error_t err = SR_ERR_OK; + char **ifaces = NULL; sr_data_t *cfg; switch (event) { @@ -397,10 +385,9 @@ static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module global = lydx_get_descendant(tree, "firewall", NULL); /* Get L3 interfaces for default zone assignment */ - char **l3_ifaces = NULL; - if (ietf_interfaces_get_all_l3(tree, &l3_ifaces) != 0) { + if (ietf_interfaces_get_all_l3(tree, &ifaces) != 0) { ERROR("Failed to get L3 interfaces"); - l3_ifaces = NULL; + ifaces = NULL; } err = srx_get_diff(session, &diff); @@ -419,82 +406,67 @@ static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module reload_needed = true; } - /* Stage 1: Generate explicit zones (skip default zone) */ - const char *default_zone = lydx_get_cattr(global, "default"); - list = lydx_get_descendant(diff, "firewall", "zone", NULL); - LYX_LIST_FOR_EACH(list, node, "zone") { - const char *name = lydx_get_cattr(node, "name"); - - if (lydx_get_op(node) == LYDX_OP_DELETE) { - delete_file(FIREWALLD_ZONES_DIR, name); - reload_needed = true; - continue; + /* Simplified approach: regenerate everything if anything in firewall changed */ + if (lydx_get_descendant(diff, "firewall", NULL)) { + const char *default_zone = lydx_get_cattr(global, "default"); + struct lyd_node *list, *node; + + /* First, handle deletions by removing files */ + list = lydx_get_descendant(diff, "firewall", "zone", NULL); + LYX_LIST_FOR_EACH(list, node, "zone") { + if (lydx_get_op(node) == LYDX_OP_DELETE) + delete_file(FIREWALLD_ZONES_DIR, lydx_get_cattr(node, "name")); } - + + list = lydx_get_descendant(diff, "firewall", "service", NULL); + LYX_LIST_FOR_EACH(list, node, "service") { + if (lydx_get_op(node) == LYDX_OP_DELETE) + delete_file(FIREWALLD_SERVICES_DIR, lydx_get_cattr(node, "name")); + } + + list = lydx_get_descendant(diff, "firewall", "policy", NULL); + LYX_LIST_FOR_EACH(list, node, "policy") { + if (lydx_get_op(node) == LYDX_OP_DELETE) + delete_file(FIREWALLD_POLICIES_DIR, lydx_get_cattr(node, "name")); + } + + /* Regenerate all non-default zones first */ clist = lydx_get_descendant(tree, "firewall", "zone", NULL); LYX_LIST_FOR_EACH(clist, cnode, "zone") { - if (strcmp(name, lydx_get_cattr(cnode, "name"))) - continue; - - /* Skip default zone in stage 1 */ - if (!strcmp(name, default_zone)) { - if (l3_ifaces) - mark_interfaces_used(cnode, l3_ifaces); - break; - } - - generate_zone(name, cnode); - if (l3_ifaces) - mark_interfaces_used(cnode, l3_ifaces); - reload_needed = true; - break; - } - } - - /* Stage 2: Generate default zone with remaining interfaces */ - if (l3_ifaces && generate_default_zone(default_zone, tree, l3_ifaces) == 0) - reload_needed = true; - - list = lydx_get_descendant(diff, "firewall", "service", NULL); - LYX_LIST_FOR_EACH(list, node, "service") { - const char *name = lydx_get_cattr(node, "name"); + const char *name = lydx_get_cattr(cnode, "name"); - if (lydx_get_op(node) == LYDX_OP_DELETE) { - delete_file(FIREWALLD_SERVICES_DIR, name); - reload_needed = true; - continue; - } - - clist = lydx_get_descendant(tree, "firewall", "service", NULL); - LYX_LIST_FOR_EACH(clist, cnode, "service") { - if (strcmp(name, lydx_get_cattr(cnode, "name"))) + /* Skip default zone - we'll do it last */ + if (!strcmp(name, default_zone)) continue; - - generate_service(name, cnode); - reload_needed = true; - break; + + mark_interfaces_used(cnode, ifaces); + generate_zone(name, cnode, NULL); } - } - - list = lydx_get_descendant(diff, "firewall", "policy", NULL); - LYX_LIST_FOR_EACH(list, node, "policy") { - const char *name = lydx_get_cattr(node, "name"); + + /* Generate default zone last with any unzoned interfaces */ + clist = lydx_get_descendant(tree, "firewall", "zone", NULL); + LYX_LIST_FOR_EACH(clist, cnode, "zone") { + const char *name = lydx_get_cattr(cnode, "name"); - if (lydx_get_op(node) == LYDX_OP_DELETE) { - delete_file(FIREWALLD_POLICIES_DIR, name); - reload_needed = true; - continue; - } - - clist = lydx_get_descendant(tree, "firewall", "policy", NULL); - LYX_LIST_FOR_EACH(clist, cnode, "policy") { - if (strcmp(name, lydx_get_cattr(cnode, "name"))) + if (strcmp(name, default_zone)) continue; - generate_policy(name, cnode); - reload_needed = true; + mark_interfaces_used(cnode, ifaces); + generate_zone(name, cnode, ifaces); break; } + + /* Regenerate all services */ + clist = lydx_get_descendant(tree, "firewall", "service", NULL); + LYX_LIST_FOR_EACH(clist, cnode, "service") + generate_service(lydx_get_cattr(cnode, "name"), cnode); + + /* Regenerate all policies */ + clist = lydx_get_descendant(tree, "firewall", "policy", NULL); + LYX_LIST_FOR_EACH(clist, cnode, "policy") + generate_policy(lydx_get_cattr(cnode, "name"), cnode); + + reload_needed = true; } if (reload_needed) @@ -503,10 +475,10 @@ static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module done: systemf("initctl -nbq %s firewalld", global ? "enable" : "disable"); - if (l3_ifaces) { - for (int i = 0; l3_ifaces[i]; i++) - free(l3_ifaces[i]); - free(l3_ifaces); + if (ifaces) { + for (int i = 0; ifaces[i]; i++) + free(ifaces[i]); + free(ifaces); } lyd_free_tree(diff); From a394311f93337d92e7a76ad0d6a21e13664b7e52 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 17 Aug 2025 21:14:06 +0200 Subject: [PATCH 27/55] confd: rename zone/policy 'policy' -> 'action', like Ubiquity Signed-off-by: Joachim Wiberg --- src/confd/share/factory.d/10-firewall.json | 4 +- src/confd/src/infix-firewall.c | 164 ++++++++++----------- src/confd/yang/confd/infix-firewall.yang | 14 +- src/statd/python/cli_pretty/cli_pretty.py | 20 +-- src/statd/python/yanger/infix_firewall.py | 10 +- 5 files changed, 106 insertions(+), 106 deletions(-) diff --git a/src/confd/share/factory.d/10-firewall.json b/src/confd/share/factory.d/10-firewall.json index 876336468..bdff61a4f 100644 --- a/src/confd/share/factory.d/10-firewall.json +++ b/src/confd/share/factory.d/10-firewall.json @@ -5,13 +5,13 @@ { "name": "internal", "description": "Internal trusted network, forwarding between networks in same zone.", - "policy": "accept", + "action": "accept", "forwarding": true }, { "name": "external", "description": "External untrusted network, only SSH and DHCPv6 client allowed in.", - "policy": "drop", + "action": "drop", "service": [ "ssh", "dhcpv6-client" ] } ] diff --git a/src/confd/src/infix-firewall.c b/src/confd/src/infix-firewall.c index ca3797415..5a504531e 100644 --- a/src/confd/src/infix-firewall.c +++ b/src/confd/src/infix-firewall.c @@ -27,7 +27,7 @@ static struct { const char *yang; const char *target; -} zone_policy_map[] = { +} zone_action_map[] = { { "reject", "%%REJECT%%" }, { "accept", "ACCEPT" }, { "drop", "DROP" }, @@ -43,14 +43,14 @@ static struct { { "drop", "DROP" }, }; -static const char *zone_policy_to_target(const char *policy) +static const char *zone_action_to_target(const char *action) { - for (size_t i = 0; policy && i < NELEMS(zone_policy_map); i++) { - if (!strcmp(policy, zone_policy_map[i].yang)) - return zone_policy_map[i].target; + for (size_t i = 0; action && i < NELEMS(zone_action_map); i++) { + if (!strcmp(action, zone_action_map[i].yang)) + return zone_action_map[i].target; } - - return zone_policy_map[0].yang; + + return zone_action_map[0].yang; } static const char *policy_action_to_target(const char *action) @@ -59,7 +59,7 @@ static const char *policy_action_to_target(const char *action) if (!strcmp(action, policy_action_map[i].yang)) return policy_action_map[i].target; } - + return policy_action_map[0].yang; } @@ -111,13 +111,13 @@ static void log_unzoned(const char *name, char **ifaces) static FILE *open_file(const char *dir, const char *name) { FILE *fp; - + fp = fopenf("w", "%s/%s.xml", dir, name); if (!fp) { ERRNO("Failed creating %s/%s.xml: %s", dir, name, strerror(errno)); return NULL; } - + fprintf(fp, "\n"); return fp; } @@ -134,24 +134,24 @@ static int delete_file(const char *dir, const char *name) ERRNO("Failed deleting %s/%s.xml: %s", dir, name, strerror(errno)); return SR_ERR_SYS; } - + return SR_ERR_OK; } -static int generate_zone(const char *name, struct lyd_node *cfg, char **ifaces) +static int generate_zone(struct lyd_node *cfg, const char *name, char **ifaces) { - const char *policy, *desc; + const char *action, *desc; struct lyd_node *node; FILE *fp; - + fp = open_file(FIREWALLD_ZONES_DIR, name); if (!fp) return SR_ERR_SYS; - - policy = lydx_get_cattr(cfg, "policy"); + + action = lydx_get_cattr(cfg, "action"); desc = lydx_get_cattr(cfg, "description"); - - fprintf(fp, "\n", zone_policy_to_target(policy)); + + fprintf(fp, "\n", zone_action_to_target(action)); fprintf(fp, " %s\n", name); if (desc) fprintf(fp, " %s\n", desc); @@ -179,42 +179,42 @@ static int generate_zone(const char *name, struct lyd_node *cfg, char **ifaces) const char *port = lydx_get_cattr(node, "port"); const char *proto = lydx_get_cattr(node, "proto"); struct lyd_node *to = lydx_get_child(node, "to"); - + if (to) { const char *to_addr = lydx_get_cattr(to, "addr"); const char *to_port = lydx_get_cattr(to, "port"); - + fprintf(fp, " \n"); } } - + #if 0 /* ADVANCED FEATURE: icmp-blocks removed from YANG model */ LYX_LIST_FOR_EACH(lyd_child(cfg), node, "icmp-blocks") { const char *icmp_type = lydx_get_cattr(node, "icmp-type"); fprintf(fp, " \n", icmp_type); } #endif - + if (lydx_is_enabled(cfg, "forwarding")) fprintf(fp, " \n"); #if 0 /* REMOVED: Zone-level masquerade - handled by policy rules instead */ if (lydx_is_enabled(cfg, "masquerade")) fprintf(fp, " \n"); #endif - + fprintf(fp, "\n"); - + return close_file(fp); } -static int generate_service(const char *name, struct lyd_node *service_cfg) +static int generate_service(struct lyd_node *cfg, const char *name) { const char *desc; #if 0 /* ADVANCED FEATURE: destination variable for service destinations */ @@ -222,80 +222,80 @@ static int generate_service(const char *name, struct lyd_node *service_cfg) #endif struct lyd_node *node; FILE *fp; - + fp = open_file(FIREWALLD_SERVICES_DIR, name); if (!fp) return SR_ERR_SYS; - - desc = lydx_get_cattr(service_cfg, "description"); + + desc = lydx_get_cattr(cfg, "description"); #if 0 /* ADVANCED FEATURE: service destinations removed from YANG model */ - destination = lydx_get_cattr(service_cfg, "destination"); + destination = lydx_get_cattr(cfg, "destination"); #endif - + fprintf(fp, "\n"); - + if (desc) fprintf(fp, " %s\n", desc); - + #if 0 /* ADVANCED FEATURE: service destinations removed from YANG model */ if (destination) fprintf(fp, " \n", destination); #endif - - LYX_LIST_FOR_EACH(lyd_child(service_cfg), node, "port") { + + LYX_LIST_FOR_EACH(lyd_child(cfg), node, "port") { const char *lower = lydx_get_cattr(node, "lower"); const char *upper = lydx_get_cattr(node, "upper"); const char *proto = lydx_get_cattr(node, "proto"); - + if (upper && strcmp(lower, upper)) fprintf(fp, " \n", lower, upper, proto); else fprintf(fp, " \n", lower, proto); } - + fprintf(fp, "\n"); - + return close_file(fp); } -static int generate_policy(const char *name, struct lyd_node *policy_cfg) +static int generate_policy(struct lyd_node *cfg, const char *name) { - const char *desc, *policy; + const char *desc, *action; struct lyd_node *node; bool masquerade; FILE *fp; - + fp = open_file(FIREWALLD_POLICIES_DIR, name); if (!fp) return SR_ERR_SYS; - - desc = lydx_get_cattr(policy_cfg, "description"); - policy = lydx_get_cattr(policy_cfg, "policy"); - masquerade = lydx_is_enabled(policy_cfg, "masquerade"); - - fprintf(fp, "\n", policy_action_to_target(policy)); - + + desc = lydx_get_cattr(cfg, "description"); + action = lydx_get_cattr(cfg, "action"); + masquerade = lydx_is_enabled(cfg, "masquerade"); + + fprintf(fp, "\n", policy_action_to_target(action)); + if (desc) fprintf(fp, " %s\n", desc); - - LYX_LIST_FOR_EACH(lyd_child(policy_cfg), node, "ingress") + + LYX_LIST_FOR_EACH(lyd_child(cfg), node, "ingress") fprintf(fp, " \n", lyd_get_value(node)); - - LYX_LIST_FOR_EACH(lyd_child(policy_cfg), node, "egress") + + LYX_LIST_FOR_EACH(lyd_child(cfg), node, "egress") fprintf(fp, " \n", lyd_get_value(node)); - - LYX_LIST_FOR_EACH(lyd_child(policy_cfg), node, "service") + + LYX_LIST_FOR_EACH(lyd_child(cfg), node, "service") fprintf(fp, " \n", lyd_get_value(node)); - + if (masquerade) fprintf(fp, " \n"); - + fprintf(fp, "\n"); - + return close_file(fp); } -static int generate_firewalld_conf(struct lyd_node *tree) +static int generate_firewalld_conf(struct lyd_node *cfg) { FILE *fp; @@ -305,13 +305,13 @@ static int generate_firewalld_conf(struct lyd_node *tree) return SR_ERR_SYS; } - fprintf(fp, "DefaultZone=%s\n", lydx_get_cattr(tree, "default")); + fprintf(fp, "DefaultZone=%s\n", lydx_get_cattr(cfg, "default")); fprintf(fp, "MinimalMark=100\n"); fprintf(fp, "CleanupOnExit=yes\n"); fprintf(fp, "Lockdown=no\n"); fprintf(fp, "IPv6_rpfilter=yes\n"); fprintf(fp, "IndividualCalls=no\n"); - fprintf(fp, "LogDenied=%s\n", lydx_get_cattr(tree, "logging") ?: "off"); + fprintf(fp, "LogDenied=%s\n", lydx_get_cattr(cfg, "logging") ?: "off"); fprintf(fp, "AutomaticHelpers=system\n"); fprintf(fp, "FirewallBackend=nftables\n"); fprintf(fp, "FlushAllOnReload=yes\n"); @@ -322,11 +322,11 @@ static int generate_firewalld_conf(struct lyd_node *tree) } static int infer_zone(sr_session_ctx_t *session, const char *name, const char *desc, - const char *policy, bool forwarding, const char *services[]) + const char *action, bool forwarding, const char *services[]) { int rc; - ERROR("Inferring zone %s (%s), policy %s forwarding %d", name, desc, policy, forwarding); + DEBUG("Inferring zone %s (%s), action %s forwarding %d", name, desc, action, forwarding); rc = srx_set_str(session, name, 0, XPATH "/zone[name='%s']/name", name); if (rc) @@ -336,7 +336,7 @@ static int infer_zone(sr_session_ctx_t *session, const char *name, const char *d if (rc) return rc; - rc = srx_set_str(session, policy, 0, XPATH "/zone[name='%s']/policy", name); + rc = srx_set_str(session, action, 0, XPATH "/zone[name='%s']/action", name); if (rc) return rc; @@ -389,7 +389,7 @@ static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module ERROR("Failed to get L3 interfaces"); ifaces = NULL; } - + err = srx_get_diff(session, &diff); if (err) goto err_release_data; @@ -410,68 +410,68 @@ static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module if (lydx_get_descendant(diff, "firewall", NULL)) { const char *default_zone = lydx_get_cattr(global, "default"); struct lyd_node *list, *node; - + /* First, handle deletions by removing files */ list = lydx_get_descendant(diff, "firewall", "zone", NULL); LYX_LIST_FOR_EACH(list, node, "zone") { if (lydx_get_op(node) == LYDX_OP_DELETE) delete_file(FIREWALLD_ZONES_DIR, lydx_get_cattr(node, "name")); } - + list = lydx_get_descendant(diff, "firewall", "service", NULL); LYX_LIST_FOR_EACH(list, node, "service") { if (lydx_get_op(node) == LYDX_OP_DELETE) delete_file(FIREWALLD_SERVICES_DIR, lydx_get_cattr(node, "name")); } - + list = lydx_get_descendant(diff, "firewall", "policy", NULL); LYX_LIST_FOR_EACH(list, node, "policy") { if (lydx_get_op(node) == LYDX_OP_DELETE) delete_file(FIREWALLD_POLICIES_DIR, lydx_get_cattr(node, "name")); } - + /* Regenerate all non-default zones first */ clist = lydx_get_descendant(tree, "firewall", "zone", NULL); LYX_LIST_FOR_EACH(clist, cnode, "zone") { const char *name = lydx_get_cattr(cnode, "name"); - + /* Skip default zone - we'll do it last */ if (!strcmp(name, default_zone)) continue; - + mark_interfaces_used(cnode, ifaces); - generate_zone(name, cnode, NULL); + generate_zone(cnode, name, NULL); } - + /* Generate default zone last with any unzoned interfaces */ clist = lydx_get_descendant(tree, "firewall", "zone", NULL); LYX_LIST_FOR_EACH(clist, cnode, "zone") { const char *name = lydx_get_cattr(cnode, "name"); - + if (strcmp(name, default_zone)) continue; mark_interfaces_used(cnode, ifaces); - generate_zone(name, cnode, ifaces); + generate_zone(cnode, name, ifaces); break; } - + /* Regenerate all services */ clist = lydx_get_descendant(tree, "firewall", "service", NULL); LYX_LIST_FOR_EACH(clist, cnode, "service") - generate_service(lydx_get_cattr(cnode, "name"), cnode); - + generate_service(cnode, lydx_get_cattr(cnode, "name")); + /* Regenerate all policies */ clist = lydx_get_descendant(tree, "firewall", "policy", NULL); LYX_LIST_FOR_EACH(clist, cnode, "policy") - generate_policy(lydx_get_cattr(cnode, "name"), cnode); - + generate_policy(cnode, lydx_get_cattr(cnode, "name")); + reload_needed = true; } if (reload_needed) system("initctl -nbq touch firewalld"); - + done: systemf("initctl -nbq %s firewalld", global ? "enable" : "disable"); @@ -484,7 +484,7 @@ static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module lyd_free_tree(diff); err_release_data: sr_release_data(cfg); - + return err; } diff --git a/src/confd/yang/confd/infix-firewall.yang b/src/confd/yang/confd/infix-firewall.yang index af5edbafe..215542e96 100644 --- a/src/confd/yang/confd/infix-firewall.yang +++ b/src/confd/yang/confd/infix-firewall.yang @@ -39,8 +39,8 @@ module infix-firewall { } } - typedef zone-policy { - description "Default policy for a zone."; + typedef zone-action { + description "Default action for a zone."; type enumeration { enum accept { @@ -227,11 +227,11 @@ module infix-firewall { type string; } - leaf policy { + leaf action { description "Default action for traffic matching no explicit rule. Note, see also the 'forwarding' setting for this zone."; - type zone-policy; + type zone-action; default reject; } @@ -252,7 +252,7 @@ module infix-firewall { leaf forwarding { description "Allow forwarding between interfaces/networks in the same zone. - Note, this setting applies regardless of (before) the zone policy!"; + Note, this setting applies regardless of (before) the zone action!"; type boolean; } @@ -336,8 +336,8 @@ module infix-firewall { type boolean; } - leaf policy { - description "Policy for non-matching traffic. All policies are terminal (accept/reject/drop)."; + leaf action { + description "Action for non-matching traffic. All policies are terminal (accept/reject/drop)."; type policy-action; default "reject"; } diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index 25adaddfb..ac6fd3d4c 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -1515,7 +1515,7 @@ def build_policy_map(policies): ingress_zones = policy.get('ingress', []) egress_zones = policy.get('egress', []) services = policy.get('service', []) - policy_action = policy.get('policy', 'reject') + action = policy.get('action', 'reject') policy_name = policy.get('name', 'unknown') for ing in ingress_zones: @@ -1531,7 +1531,7 @@ def build_policy_map(policies): 'policies': [] } - if policy_action in ['accept', 'continue']: + if action in ['accept', 'continue']: policy_map[key]['allow'] = True if services: @@ -1558,7 +1558,7 @@ def make_cell(symbol, bg_func): # Check if from_zone has port forwarding rules (makes it conditional) zone = next((z for z in zones if z.get('name') == from_zone), None) if zone: - action = zone.get('policy', 'accept') + action = zone.get('action', 'accept') pfwd = zone.get('port-forward', []) if action in ['reject', 'drop'] and pfwd: # Some traffic allowed via port forwarding @@ -1676,8 +1676,8 @@ def show_firewall_zone(json, zone_name=None): services = zone.get('service', []) if not services: services = "" - policy = zone.get('policy', 'accept') - if policy == 'accept': + action = zone.get('action', 'accept') + if action == 'accept': services_display = "(any)" else: services_display = ", ".join(services) if services else "(none)" @@ -1685,7 +1685,7 @@ def show_firewall_zone(json, zone_name=None): print(format_description('description', description)) print(f"{'name':<20}: {zone_name}") - print(f"{'policy':<20}: {policy}") + print(f"{'action':<20}: {action}") print(f"{'interfaces':<20}: {', '.join(interfaces)}") print(f"{'sources':<20}: {', '.join(sources)}") print(f"{'forwarding':<20}: {forwarding}") @@ -1741,7 +1741,7 @@ def show_firewall_zone(json, zone_name=None): print(Decore.invert(hdr)) for zone in zones: name = zone.get('name', '') - action = zone.get('policy', 'accept') + action = zone.get('action', 'accept') interfaces = ", ".join(zone.get('interface', [])) if not interfaces: interfaces = "(none)" @@ -1771,7 +1771,7 @@ def show_firewall_policy(json, policy_name=None): ingress = policy.get('ingress', []) egress = policy.get('egress', []) - policy_action = policy.get('policy', 'reject') + action = policy.get('action', 'reject') masquerade = "yes" if policy.get('masquerade') else "no" description = policy.get('description', '') services = policy.get('service', []) @@ -1784,7 +1784,7 @@ def show_firewall_policy(json, policy_name=None): print(f"{'name':<20}: {policy_name}") print(f"{'ingress':<20}: {', '.join(ingress) if ingress else '(none)'}") print(f"{'egress':<20}: {', '.join(egress) if egress else '(none)'}") - print(f"{'policy':<20}: {policy_action}") + print(f"{'action':<20}: {action}") print(f"{'masquerade':<20}: {masquerade}") print(f"{'services':<20}: {services_display}") else: @@ -1798,7 +1798,7 @@ def show_firewall_policy(json, policy_name=None): name = policy.get('name', '') ingress = ", ".join(policy.get('ingress', [])) egress = ", ".join(policy.get('egress', [])) - action = policy.get('policy', 'reject') + action = policy.get('action', 'reject') print(f"{name:<{PadFirewall.policy_name}}" f"{action:<{PadFirewall.policy_action}}" diff --git a/src/statd/python/yanger/infix_firewall.py b/src/statd/python/yanger/infix_firewall.py index 8f34361cb..c78e86a13 100644 --- a/src/statd/python/yanger/infix_firewall.py +++ b/src/statd/python/yanger/infix_firewall.py @@ -34,7 +34,7 @@ def get_zone_data(fw, name): settings = fw.getZoneSettings2(name) zone = { "name": name, - "policy": "accept", + "action": "accept", "interface": list(settings.get('interfaces', [])), "source": list(settings.get('sources', [])), "service": list(settings.get('services', [])), @@ -43,14 +43,14 @@ def get_zone_data(fw, name): } target = settings.get('target', 'default') - policy = { + action = { "%%REJECT%%": "reject", "REJECT": "reject", "ACCEPT": "accept", "DROP": "drop", "default": "accept" } - zone["policy"] = policy.get(target, "accept") + zone["action"] = action.get(target, "accept") zone["forwarding"] = bool(settings.get('forward', 0)) zone["description"] = settings.get('description', 0) @@ -123,7 +123,7 @@ def get_policy_data(fw, name): policy = { "name": name, - "policy": "reject", + "action": "reject", # "priority": 32767, "ingress": [], "egress": [] @@ -136,7 +136,7 @@ def get_policy_data(fw, name): "REJECT": "reject", "DROP": "drop" } - policy["policy"] = action.get(target, "reject") + policy["action"] = action.get(target, "reject") # priority = settings.get('priority', 32767) # if isinstance(priority, int): From b202455eb686c6f91c130d8578ca5b436848d6a5 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 17 Aug 2025 21:39:42 +0200 Subject: [PATCH 28/55] confd: rename sources -> networks, for IP networks Also, add check for overlap between zones that have a port forward rule to an IP network that lives in another zone. Raise conditional in the zone matrix overview! Signed-off-by: Joachim Wiberg --- doc/TODO.org | 10 ++--- src/confd/src/infix-firewall.c | 2 +- src/confd/yang/confd/infix-firewall.yang | 8 ++-- src/statd/python/cli_pretty/cli_pretty.py | 52 ++++++++++++++++++++--- src/statd/python/yanger/infix_firewall.py | 6 +-- 5 files changed, 60 insertions(+), 18 deletions(-) diff --git a/doc/TODO.org b/doc/TODO.org index 67901960d..12edee284 100644 --- a/doc/TODO.org +++ b/doc/TODO.org @@ -8,11 +8,11 @@ be queried using =firewall-cmd --query-panic=, which returns =yes= - [ ] Remove debug log messages! - [ ] Add "Log Messages" section to =show firewall= when =LogDenied ≠ off= -- [ ] Rename policy->policy to policy->action, and replace allow->forward -- [ ] Rename zone->sources to networks -- [ ] A zone's policy is for ingress, clarify this if missing! -- [ ] Any services/ports listed in a zone with policy:accept are a NO-OP -- [ ] =firwall-cmd --reload= takes fooooorever! :-( +- [1/2] Rename policy->policy to policy->action, and replace allow->forward +- [X] Rename zone->sources to networks +- [X] A zone's action is for ingress, clarify this if missing! +- [X] Any services/ports listed in a zone with policy:accept are a NO-OP +- [X] =firwall-cmd --reload= takes fooooorever! :-( * TODO doc: User Guide diff --git a/src/confd/src/infix-firewall.c b/src/confd/src/infix-firewall.c index 5a504531e..802c8672f 100644 --- a/src/confd/src/infix-firewall.c +++ b/src/confd/src/infix-firewall.c @@ -169,7 +169,7 @@ static int generate_zone(struct lyd_node *cfg, const char *name, char **ifaces) log_unzoned(name, ifaces); } - LYX_LIST_FOR_EACH(lyd_child(cfg), node, "source") + LYX_LIST_FOR_EACH(lyd_child(cfg), node, "network") fprintf(fp, " \n", lyd_get_value(node)); LYX_LIST_FOR_EACH(lyd_child(cfg), node, "service") diff --git a/src/confd/yang/confd/infix-firewall.yang b/src/confd/yang/confd/infix-firewall.yang index 215542e96..c372c2cef 100644 --- a/src/confd/yang/confd/infix-firewall.yang +++ b/src/confd/yang/confd/infix-firewall.yang @@ -228,7 +228,7 @@ module infix-firewall { } leaf action { - description "Default action for traffic matching no explicit rule. + description "Default for ingressing traffic not matching an explicit rule. Note, see also the 'forwarding' setting for this zone."; type zone-action; @@ -244,8 +244,8 @@ module infix-firewall { } } - leaf-list source { - description "Source networks assigned to this zone."; + leaf-list network { + description "IP networks assigned to this zone."; type inet:ip-prefix; } @@ -285,7 +285,7 @@ module infix-firewall { } leaf-list service { - description "Services allowed to ingress this zone."; + description "Services allowed to ingress zone when action != accept."; type union { type leafref { path "../../service/name"; diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index ac6fd3d4c..51aa3f68a 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -1507,6 +1507,40 @@ def show_firewall(json): show_firewall_policy(json) +def ip_in_network(ip_addr, network): + """Check if an IP address falls within a CIDR network""" + try: + import ipaddress + ip = ipaddress.ip_address(ip_addr) + net = ipaddress.ip_network(network, strict=False) + return ip in net + except: + return False + + +def pfw_cond(zones): + """Check for port-forwards that target IPs within zone networks""" + cond = [] + + # Collect all port-forwards with destinations + pfwd = [] + for zone in zones: + zone_name = zone.get('name', '') + for pf in zone.get('port-forward', []): + to = pf.get('to', {}) + if 'addr' in to: + pfwd.append((zone_name, to['addr'])) + + # Check each destination against all zone networks + for zone in zones: + zone_name = zone.get('name', '') + for network in zone.get('network', []): + for pf_zone, dest_ip in pfwd: + if ip_in_network(dest_ip, network): + cond.append(f"Port-forward in {pf_zone} targets {dest_ip} in {zone_name} network {network}") + return cond + + def build_policy_map(policies): """Build enhanced policy lookup with conditional detection""" policy_map = {} @@ -1554,6 +1588,10 @@ def make_cell(symbol, bg_func): key = (from_zone, to_zone) policy = policy_map.get(key) + # Check for port-forward network overlap conditionals + cond = pfw_cond(zones) + pfwd_overlap = any(from_zone in cond or to_zone in cond for cond in cond) + if not policy or not policy['allow']: # Check if from_zone has port forwarding rules (makes it conditional) zone = next((z for z in zones if z.get('name') == from_zone), None) @@ -1564,9 +1602,13 @@ def make_cell(symbol, bg_func): # Some traffic allowed via port forwarding return make_cell("⚠", Decore.yellow_bg) + # Check for port-forward network overlap + if pfwd_overlap: + return make_cell("⚠", Decore.yellow_bg) + return make_cell("✗", Decore.red_bg) - if policy['conditional']: + if policy['conditional'] or pfwd_overlap: return make_cell("⚠", Decore.yellow_bg) return make_cell("✓", Decore.green_bg) @@ -1670,9 +1712,9 @@ def show_firewall_zone(json, zone_name=None): interfaces = zone.get('interface', []) if not interfaces: interfaces = "" - sources = zone.get('source', []) - if not sources: - sources = "" + networks = zone.get('network', []) + if not networks: + networks = "" services = zone.get('service', []) if not services: services = "" @@ -1687,7 +1729,7 @@ def show_firewall_zone(json, zone_name=None): print(f"{'name':<20}: {zone_name}") print(f"{'action':<20}: {action}") print(f"{'interfaces':<20}: {', '.join(interfaces)}") - print(f"{'sources':<20}: {', '.join(sources)}") + print(f"{'networks':<20}: {', '.join(networks)}") print(f"{'forwarding':<20}: {forwarding}") print(f"{'services':<20}: {services_display}") diff --git a/src/statd/python/yanger/infix_firewall.py b/src/statd/python/yanger/infix_firewall.py index c78e86a13..9f4d77045 100644 --- a/src/statd/python/yanger/infix_firewall.py +++ b/src/statd/python/yanger/infix_firewall.py @@ -36,7 +36,7 @@ def get_zone_data(fw, name): "name": name, "action": "accept", "interface": list(settings.get('interfaces', [])), - "source": list(settings.get('sources', [])), + "network": list(settings.get('sources', [])), "service": list(settings.get('services', [])), "port-forward": [], "forwarding": False @@ -106,9 +106,9 @@ def get_zones(fw): for name, zone_info in active_zones.items(): zone_data = get_zone_data(fwz, name) if zone_data: - # Override interfaces and sources with active zone data to ensure accuracy + # Override interfaces and networks with active zone data to ensure accuracy zone_data['interface'] = list(zone_info.get('interfaces', [])) - zone_data['source'] = list(zone_info.get('sources', [])) + zone_data['network'] = list(zone_info.get('sources', [])) zones.append(zone_data) except Exception as e: From 6a0582f0f9c1ce1bda2b8fd614a8b03404f320dd Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 18 Aug 2025 06:43:25 +0200 Subject: [PATCH 29/55] confd: refactor port-forwarding to allow forwarding a range of ports Signed-off-by: Joachim Wiberg --- doc/TODO.org | 4 +-- src/confd/src/infix-firewall.c | 34 +++++++++++++++++----- src/confd/yang/confd/infix-firewall.yang | 19 ++++++++---- src/statd/python/cli_pretty/cli_pretty.py | 32 ++++++++++++++++----- src/statd/python/yanger/infix_firewall.py | 35 +++++++++++++++-------- 5 files changed, 91 insertions(+), 33 deletions(-) diff --git a/doc/TODO.org b/doc/TODO.org index 12edee284..45547a056 100644 --- a/doc/TODO.org +++ b/doc/TODO.org @@ -1,12 +1,12 @@ * TODO Add support for firewall - [X] Interfaces are not defaulting to the default zone, must handle bridge ports and changes to enslavement, so regenerate every time is a must! -- [ ] Add missing upper to port forward since port ranges are supported, see services! +- [X] Add missing upper to port forward since port ranges are supported, see services! - [X] =[do] show firewall= not available yet - [ ] Add RPC to pause firewall using =firewall-cmd --panic-on= and restart firewall again with =firewall-cmd --panic-off=. The current state can be queried using =firewall-cmd --query-panic=, which returns =yes= -- [ ] Remove debug log messages! +- [X] Remove debug log messages! - [ ] Add "Log Messages" section to =show firewall= when =LogDenied ≠ off= - [1/2] Rename policy->policy to policy->action, and replace allow->forward - [X] Rename zone->sources to networks diff --git a/src/confd/src/infix-firewall.c b/src/confd/src/infix-firewall.c index 802c8672f..d0b757c6f 100644 --- a/src/confd/src/infix-firewall.c +++ b/src/confd/src/infix-firewall.c @@ -176,7 +176,8 @@ static int generate_zone(struct lyd_node *cfg, const char *name, char **ifaces) fprintf(fp, " \n", lyd_get_value(node)); LYX_LIST_FOR_EACH(lyd_child(cfg), node, "port-forward") { - const char *port = lydx_get_cattr(node, "port"); + const char *lower = lydx_get_cattr(node, "lower"); + const char *upper = lydx_get_cattr(node, "upper"); const char *proto = lydx_get_cattr(node, "proto"); struct lyd_node *to = lydx_get_child(node, "to"); @@ -184,14 +185,33 @@ static int generate_zone(struct lyd_node *cfg, const char *name, char **ifaces) const char *to_addr = lydx_get_cattr(to, "addr"); const char *to_port = lydx_get_cattr(to, "port"); - fprintf(fp, " \n"); + fprintf(fp, "/>\n"); + } else { + /* Single port */ + fprintf(fp, " \n"); + } } } diff --git a/src/confd/yang/confd/infix-firewall.yang b/src/confd/yang/confd/infix-firewall.yang index c372c2cef..28dfa6343 100644 --- a/src/confd/yang/confd/infix-firewall.yang +++ b/src/confd/yang/confd/infix-firewall.yang @@ -258,13 +258,19 @@ module infix-firewall { list port-forward { description "Forward traffic another port and/or host (DNAT)."; - key "port proto"; + key "lower proto"; - leaf port { + leaf lower { description "Local port to forward from."; type inet:port-number; } + leaf upper { + description "Upper port when forwarding a range of ports."; + type inet:port-number; + must "../lower <= ."; + } + leaf proto { description "Network protocol."; type protocol-type; @@ -278,7 +284,10 @@ module infix-firewall { } leaf port { - description "Port to forward to."; + description "Port to forward to. + + When forwarding a range of ports this is the lower + and the upper is then automatically calculated."; type inet:port-number; } } @@ -375,14 +384,14 @@ module infix-firewall { key "lower proto"; leaf lower { - type inet:port-number; description "Lower port in range."; + type inet:port-number; } leaf upper { + description "Upper port in range."; type inet:port-number; must "../lower <= ."; - description "Upper port in range."; } leaf proto { diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index 51aa3f68a..651818b52 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -1529,15 +1529,21 @@ def pfw_cond(zones): for pf in zone.get('port-forward', []): to = pf.get('to', {}) if 'addr' in to: - pfwd.append((zone_name, to['addr'])) + lower = pf.get('lower') + upper = pf.get('upper') + pfwd.append((zone_name, to['addr'], lower, upper)) # Check each destination against all zone networks for zone in zones: zone_name = zone.get('name', '') for network in zone.get('network', []): - for pf_zone, dest_ip in pfwd: + for pf_zone, dest_ip, lower, upper in pfwd: if ip_in_network(dest_ip, network): - cond.append(f"Port-forward in {pf_zone} targets {dest_ip} in {zone_name} network {network}") + if upper: + port_desc = f"ports {lower}-{upper}" + else: + port_desc = f"port {lower}" + cond.append(f"Port-forward in {pf_zone} ({port_desc}) targets {dest_ip} in {zone_name} network {network}") return cond @@ -1741,12 +1747,24 @@ def show_firewall_zone(json, zone_name=None): print(Decore.invert(hdr)) for fwd in port_forwards: - lower = fwd.get('port', {}) - proto = fwd.get('proto', {}) - from_port = f"{lower}/{proto}" + lower = fwd.get('lower') + upper = fwd.get('upper') + proto = fwd.get('proto', '') + + if upper: + from_port = f"{lower}-{upper}/{proto}" + else: + from_port = f"{lower}/{proto}" to = fwd.get('to', {}) - to_str = f"{to.get('addr', '')}:{to.get('port', lower)}" + to_port = to.get('port', lower) + + if upper: + range_size = upper - lower + to_upper = to_port + range_size + to_str = f"{to.get('addr', '')}:{to_port}-{to_upper}" + else: + to_str = f"{to.get('addr', '')}:{to_port}" print(f"{from_port:<{PadFirewall.zone_pfwd_from}}" f"{to_str:<{PadFirewall.zone_pfwd_to}}") diff --git a/src/statd/python/yanger/infix_firewall.py b/src/statd/python/yanger/infix_firewall.py index 9f4d77045..9b2af2d39 100644 --- a/src/statd/python/yanger/infix_firewall.py +++ b/src/statd/python/yanger/infix_firewall.py @@ -60,27 +60,38 @@ def get_zone_data(fw, name): if len(fwd) >= 4: port, protocol, toport, toaddr = fwd[:4] # Fixed field order! - # TODO: extend YANG model with lower/upper - fwd_data = { - 'port': int(port), # TODO: Single port number now. - 'proto': str(protocol), - 'to': { - 'addr': str(toaddr) + # Handle port ranges: port can be "80" or "8000-8080" + if '-' in str(port): + port_lower, port_upper = str(port).split('-', 1) + fwd_data = { + 'lower': int(port_lower), + 'upper': int(port_upper), + 'proto': str(protocol), + 'to': { + 'addr': str(toaddr) + } + } + else: + fwd_data = { + 'lower': int(port), + 'proto': str(protocol), + 'to': { + 'addr': str(toaddr) + } } - } - # Handle destination port - might be empty or IP + # Handle destination port - only store lower port, upper calculated by C code if toport and str(toport).strip(): toport_str = str(toport).strip() # Skip if toport looks like an IP address instead of port if '.' not in toport_str and ':' not in toport_str: fwd_data['to']['port'] = int(toport_str) else: - # If toport looks like IP, use the same port as source - fwd_data['to']['port'] = int(port) + # If toport looks like IP, use the same port as source lower + fwd_data['to']['port'] = fwd_data['lower'] else: - # No destination port specified, use same as source - fwd_data['to']['port'] = int(port) + # No destination port specified, use same as source lower + fwd_data['to']['port'] = fwd_data['lower'] zone["port-forward"].append(fwd_data) From e24ade5dfdcdb2d68559ad10997d04615dc5ca4d Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 18 Aug 2025 08:01:36 +0200 Subject: [PATCH 30/55] statd: initial support for /var/log/firewall.log Signed-off-by: Joachim Wiberg --- .../common/rootfs/etc/syslog.d/firewall.conf | 6 + doc/TODO.org | 3 +- src/statd/python/cli_pretty/cli_pretty.py | 122 ++++++++++++++++++ 3 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 board/common/rootfs/etc/syslog.d/firewall.conf diff --git a/board/common/rootfs/etc/syslog.d/firewall.conf b/board/common/rootfs/etc/syslog.d/firewall.conf new file mode 100644 index 000000000..d3ab25c56 --- /dev/null +++ b/board/common/rootfs/etc/syslog.d/firewall.conf @@ -0,0 +1,6 @@ +# Log firewall denied/rejected packet logs to dedicated file +# https://www.cyberciti.biz/faq/enable-firewalld-logging-for-denied-packets-on-linux/ +:msg, contains, "_DROP" +kern.* -/var/log/firewall.log +:msg, contains, "_REJECT" +kern.* -/var/log/firewall.log diff --git a/doc/TODO.org b/doc/TODO.org index 45547a056..b0c61e591 100644 --- a/doc/TODO.org +++ b/doc/TODO.org @@ -7,7 +7,8 @@ firewall again with =firewall-cmd --panic-off=. The current state can be queried using =firewall-cmd --query-panic=, which returns =yes= - [X] Remove debug log messages! -- [ ] Add "Log Messages" section to =show firewall= when =LogDenied ≠ off= +- [X] Add "Log Messages" section to =show firewall= when =LogDenied ≠ off= +- [ ] Investigate filtering out firewall log messages from other log files - [1/2] Rename policy->policy to policy->action, and replace allow->forward - [X] Rename zone->sources to networks - [X] A zone's action is for ingress, clarify this if missing! diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index 651818b52..a772d9a07 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -4,10 +4,13 @@ import sys import re import textwrap +import ipaddress +from collections import deque from datetime import datetime, timezone UNIT_TEST = False + class Pad: iface = 16 proto = 11 @@ -118,6 +121,14 @@ class PadFirewall: service_name = 20 service_ports = 69 + # Firewall log display formatting + log_time = 16 # ISO format: MM-DD HH:MM:SS + log_action = 7 # REJECT/DROP + small buffer + log_src = 25 # IPv6 addresses (shortened) or IPv4 + log_dst = 25 # IPv6 addresses (shortened) or IPv4 + log_proto = 6 # TCP/UDP/ICMP + small buffer + log_port = 5 # Port numbers + small buffer + @classmethod def table_width(cls): """Table width for zones/policies tables, used to center matrix""" @@ -1490,6 +1501,113 @@ def show_lldp(json): entry.print() +def parse_firewall_log_line(line): + """Parse a single firewall log line into structured data""" + + # Look for kernel logs with netfilter IN=/OUT= fields + if not ('kernel' in line and 'IN=' in line and 'OUT=' in line): + return None + + # Extract timestamp from syslog format: Aug 17 12:34:56 + timestamp_match = re.match(r'^(\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})', line.strip()) + if not timestamp_match: + return None + + timestamp = timestamp_match.group(1) + + # Look for action indicator in the log line + action = 'DROP' + if 'REJECT' in line: + action = 'REJECT' + + # Extract key fields from netfilter log + patterns = { + 'in_iface': r'IN=([^\s]*)', + 'out_iface': r'OUT=([^\s]*)', + 'src': r'SRC=([^\s]+)', + 'dst': r'DST=([^\s]+)', + 'proto': r'PROTO=([^\s]+)', + 'spt': r'SPT=([^\s]+)', + 'dpt': r'DPT=([^\s]+)', + } + + parsed = {'timestamp': timestamp, 'action': action} + + for key, pattern in patterns.items(): + match = re.search(pattern, line) + value = match.group(1) if match else '' + + # Compress any IPv6 addresses for src and dst + if key in ['src', 'dst'] and value: + try: + ip = ipaddress.ip_address(value) + if isinstance(ip, ipaddress.IPv6Address): + value = str(ip.compressed) + except ValueError: + # Not a valid IP address, keep original value + pass + + parsed[key] = value + + return parsed + + +def show_firewall_logs(limit=10): + """Show recent firewall log entries, tail -N equivalent""" + try: + hdr = (f"{'TIME':<{PadFirewall.log_time}} " + f"{'ACTION':<{PadFirewall.log_action}} " + f"{'SOURCE':<{PadFirewall.log_src}} " + f"{'DEST':<{PadFirewall.log_dst}} " + f"{'PROTO':<{PadFirewall.log_proto}} " + f"{'PORT':>{PadFirewall.log_port}}") + + Decore.title("Recent Firewall Logs", len(hdr)) + + with open('/var/log/firewall.log', 'r', encoding='utf-8') as f: + lines = deque(f, maxlen=limit) + + if not lines: + raise FileNotFoundError + + print(Decore.invert(hdr)) + for line in lines: + parsed = parse_firewall_log_line(line) + if not parsed: + continue + + time_str = '' + if parsed['timestamp']: + try: + ts = parsed['timestamp'].strip() + if 'T' in ts: # ISO format + dt = datetime.fromisoformat(ts) + time_str = dt.strftime("%b %d %H:%M:%S") + else: # syslog format + dt = datetime.strptime(ts, "%b %d %H:%M:%S") + time_str = dt.strftime("%b %d %H:%M:%S") + except Exception: + time_str = parsed['timestamp'][:PadFirewall.log_time-1] + + if parsed['action'] == 'REJECT': + action_color = Decore.red + else: + action_color = Decore.yellow + action = action_color(parsed['action']) + + print(f"{time_str:<{PadFirewall.log_time}} " + f"{action:<{PadFirewall.log_action + 10}} " + f"{parsed['src']:<{PadFirewall.log_src}} " + f"{parsed['dst']:<{PadFirewall.log_dst}} " + f"{parsed['proto']:<{PadFirewall.log_proto}} " + f"{parsed['dpt']:>{PadFirewall.log_port}}") + + except FileNotFoundError: + print("No logs found (may be disabled or no denied traffic)") + except Exception as e: + print(f"Error reading firewall logs: {e}") + + def show_firewall(json): """Show firewall overview with matrix and tables""" fw = json.get('infix-firewall:firewall', {}) @@ -1506,6 +1624,10 @@ def show_firewall(json): show_firewall_zone(json) show_firewall_policy(json) + # Add firewall logs at the bottom if logging is enabled + if fw.get('logging', 'off') != 'off': + show_firewall_logs() + def ip_in_network(ip_addr, network): """Check if an IP address falls within a CIDR network""" From b1b9508b85619b678d514a55bc8b8518b6d4e8d5 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 18 Aug 2025 12:29:58 +0200 Subject: [PATCH 31/55] statd: update firewall matrix, if forwarding disabled => deny Signed-off-by: Joachim Wiberg --- doc/TODO.org | 2 ++ src/statd/python/cli_pretty/cli_pretty.py | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/doc/TODO.org b/doc/TODO.org index b0c61e591..7355da2ba 100644 --- a/doc/TODO.org +++ b/doc/TODO.org @@ -14,6 +14,8 @@ - [X] A zone's action is for ingress, clarify this if missing! - [X] Any services/ports listed in a zone with policy:accept are a NO-OP - [X] =firwall-cmd --reload= takes fooooorever! :-( +- [X] If forwarding is disabled in a zone then the zone matrix should + show deny for the same zone-to-zone communication * TODO doc: User Guide diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index a772d9a07..f31fa07c5 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -1710,8 +1710,12 @@ def make_cell(symbol, bg_func): # Create full-width colored cell return bg_func(f" {symbol:^{cell_width}} ") + # Check if forwarding is enabled for same-zone communication if from_zone == to_zone: - return make_cell("✓", Decore.green_bg) + zone = next((z for z in zones if z.get('name') == from_zone), None) + if zone and zone.get('forwarding'): + return make_cell("✓", Decore.green_bg) + return make_cell("✗", Decore.red_bg) key = (from_zone, to_zone) policy = policy_map.get(key) From 30274cccc4ce1f207b1819828f6910b16c761fef Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 18 Aug 2025 14:31:09 +0200 Subject: [PATCH 32/55] statd: show implicit HOST zone in firewall matrix Signed-off-by: Joachim Wiberg --- doc/TODO.org | 6 + src/statd/python/cli_pretty/cli_pretty.py | 128 ++++++++++++++++++---- 2 files changed, 110 insertions(+), 24 deletions(-) diff --git a/doc/TODO.org b/doc/TODO.org index 7355da2ba..fc6e7859b 100644 --- a/doc/TODO.org +++ b/doc/TODO.org @@ -16,6 +16,12 @@ - [X] =firwall-cmd --reload= takes fooooorever! :-( - [X] If forwarding is disabled in a zone then the zone matrix should show deny for the same zone-to-zone communication +- [X] We should show the implicit rules for communicating with the HOST +- [ ] Investigate "padlock" on built-in policys (and zones?) and expose more? +- [ ] Document established,related somewhere, fixed/padlocked policy? Also, + document why this is a good idea to always have enabled. See RH docs. +- [ ] Podman published ports, +- [ ] Software fastpath * TODO doc: User Guide diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index f31fa07c5..600b3d225 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -103,20 +103,21 @@ class PadLldp: class PadFirewall: zone_name = 20 zone_action = 9 - zone_interfaces = 40 - zone_services = 20 + zone_interfaces = 30 + zone_services = 30 zone_pfwd_from = 20 zone_pfwd_to = 69 zone_flow_to = 20 - zone_flow_action = 9 - zone_flow_policy = 60 + zone_flow_action = 14 + zone_flow_policy = 20 + zone_flow_services = 45 policy_name = 20 policy_action = 9 - policy_ingress = 40 - policy_egress = 20 + policy_ingress = 30 + policy_egress = 30 service_name = 20 service_ports = 69 @@ -1704,12 +1705,50 @@ def build_policy_map(policies): return policy_map -def traffic_symbol(from_zone, to_zone, policy_map, zones, cell_width): - """Determine the symbol to show for zone-to-zone traffic""" +def traffic_flow(from_zone, to_zone, policy_map, zones, cell_width): + """Apply heuristics to deterime traffic flows between zones + Args: + from_zone, to_zone : Names and direction of flow + policy_map : Map of all policies + zones : All the zone data + cell_width : Table cell width for pretty printing + + Returns: the symbol to show for zone-to-zone traffic + """ def make_cell(symbol, bg_func): # Create full-width colored cell return bg_func(f" {symbol:^{cell_width}} ") + # Handle HOST zone specially + if from_zone == "HOST" and to_zone == "HOST": + # HOST-to-HOST communication (localhost) - not applicable + return make_cell("—", Decore.gray_bg) + + # HOST-to-zone traffic: firewall input rules control this + if from_zone == "HOST": + # Traffic from firewall device to zones - typically allowed + return make_cell("✓", Decore.green_bg) + + # zone-to-HOST traffic: firewall input rules control this + if to_zone == "HOST": + # Traffic to HOST depends on zone's input action and services + zone = next((z for z in zones if z.get('name') == from_zone), None) + if zone: + action = zone.get('action', 'reject') + services = zone.get('service', []) + + if action == 'accept': + # Zone allows any input to HOST + return make_cell("✓", Decore.green_bg) + if services: + # Zone has specific services allowed to HOST + return make_cell("⚠", Decore.yellow_bg) + + # Zone has drop/reject action and no services + return make_cell("✗", Decore.red_bg) + + return make_cell("✗", Decore.red_bg) + # Check if forwarding is enabled for same-zone communication if from_zone == to_zone: zone = next((z for z in zones if z.get('name') == from_zone), None) @@ -1724,11 +1763,14 @@ def make_cell(symbol, bg_func): cond = pfw_cond(zones) pfwd_overlap = any(from_zone in cond or to_zone in cond for cond in cond) + if (policy and policy['conditional']) or pfwd_overlap: + return make_cell("⚠", Decore.yellow_bg) + if not policy or not policy['allow']: # Check if from_zone has port forwarding rules (makes it conditional) zone = next((z for z in zones if z.get('name') == from_zone), None) if zone: - action = zone.get('action', 'accept') + action = zone.get('action', 'reject') pfwd = zone.get('port-forward', []) if action in ['reject', 'drop'] and pfwd: # Some traffic allowed via port forwarding @@ -1740,9 +1782,6 @@ def make_cell(symbol, bg_func): return make_cell("✗", Decore.red_bg) - if policy['conditional'] or pfwd_overlap: - return make_cell("⚠", Decore.yellow_bg) - return make_cell("✓", Decore.green_bg) @@ -1751,11 +1790,11 @@ def show_firewall_matrix(fw): zones = fw.get('zone', []) policies = fw.get('policy', []) - # No need for a matrix if there's only one zone - if len(zones) <= 1: - return None - zone_names = [z['name'] for z in zones if z.get('interface')] + + # Always add the implicit HOST zone + zone_names.insert(0, "HOST") + if len(zone_names) <= 1: return None @@ -1806,7 +1845,9 @@ def show_firewall_matrix(fw): for from_zone in zone_names: row = f"│ {from_zone:>{left_col_width}} │" for to_zone in zone_names: - symbol = traffic_symbol(from_zone, to_zone, policy_map, zones, col_width) + # Find symbol for this cell based on traffic flow + symbol = traffic_flow(from_zone, to_zone, policy_map, + zones, col_width) row += f"{symbol}│" print(f"{indent}{row}") print(f"{indent}{bottom_border}") @@ -1850,7 +1891,7 @@ def show_firewall_zone(json, zone_name=None): services = zone.get('service', []) if not services: services = "" - action = zone.get('action', 'accept') + action = zone.get('action', 'reject') if action == 'accept': services_display = "(any)" else: @@ -1869,7 +1910,7 @@ def show_firewall_zone(json, zone_name=None): if port_forwards: hdr = (f"{'FROM':<{PadFirewall.zone_pfwd_from}}" f"{'TO':<{PadFirewall.zone_pfwd_to}}") - Decore.title("Port Forwards", len(hdr)) + Decore.title(f"Port Forwards From {zone_name}", len(hdr)) print(Decore.invert(hdr)) for fwd in port_forwards: @@ -1897,10 +1938,34 @@ def show_firewall_zone(json, zone_name=None): hdr = (f"{'TO ZONE':<{PadFirewall.zone_flow_to}}" f"{'ACTION':<{PadFirewall.zone_flow_action}}" - f"{'POLICY':<{PadFirewall.zone_flow_policy}}") - Decore.title("Traffic Flows", len(hdr)) + f"{'POLICY':<{PadFirewall.zone_flow_policy}}" + f"{'SERVICES':<{PadFirewall.zone_flow_services}}") + Decore.title(f"Traffic Flows From {zone_name} →", len(hdr)) print(Decore.invert(hdr)) + # Add HOST zone first + current_zone = next((z for z in zones if z.get('name') == zone_name), None) + if current_zone: + # Zone-to-HOST traffic logic + action = current_zone.get('action', 'reject') + services = current_zone.get('service', []) + + if action == 'accept': + host_action = "✓ allow" + host_services = "(any)" + elif services: + host_action = "⚠ conditional" + host_services = ", ".join(services) + else: + host_action = "✗ deny" + host_services = "(none)" + + print(f"{'HOST':<{PadFirewall.zone_flow_to}}" + f"{host_action:<{PadFirewall.zone_flow_action}}" + f"{'(services)':<{PadFirewall.zone_flow_policy}}" + f"{host_services}") + + # Add other zones for other_zone in zones: if other_zone.get('name') == zone_name: continue @@ -1909,15 +1974,30 @@ def show_firewall_zone(json, zone_name=None): other_name = other_zone.get('name') policy_name = "(none)" action = "✗ deny" + services_display = "(none)" + for policy in policies: if zone_name in policy.get('ingress', []) and other_name \ in policy.get('egress', []): policy_name = policy.get('name', 'unknown') - action = "✓ allow" + policy_action = policy.get('action', 'reject') + policy_services = policy.get('service', []) + + if policy_action == 'accept': + action = "✓ allow" + services_display = "(any)" + elif policy_services: + action = "⚠ conditional" + services_display = ", ".join(policy_services) + else: + action = "✗ deny" + services_display = "(none)" break + print(f"{other_name:<{PadFirewall.zone_flow_to}}" f"{action:<{PadFirewall.zone_flow_action}}" - f"{policy_name}") + f"{policy_name:<{PadFirewall.zone_flow_policy}}" + f"{services_display}") else: hdr = (f"{'NAME':<{PadFirewall.zone_name}}" f"{'ACTION':<{PadFirewall.zone_action}}" @@ -1927,7 +2007,7 @@ def show_firewall_zone(json, zone_name=None): print(Decore.invert(hdr)) for zone in zones: name = zone.get('name', '') - action = zone.get('action', 'accept') + action = zone.get('action', 'reject') interfaces = ", ".join(zone.get('interface', [])) if not interfaces: interfaces = "(none)" From 72581aa7164028929f2fef686ad64c07d77b5fbe Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 18 Aug 2025 14:32:16 +0200 Subject: [PATCH 33/55] confd: initial support for emergency lockdown (kill switch) Signed-off-by: Joachim Wiberg --- board/common/rootfs/usr/bin/yorn | 11 ++- src/confd/bin/firewall | 91 +++++++++-------------- src/confd/src/infix-firewall.c | 18 +++++ src/confd/yang/confd/infix-firewall.yang | 63 ++++++++++++++++ src/klish-plugin-infix/xml/infix.xml | 19 +++++ src/statd/python/cli_pretty/cli_pretty.py | 27 ++++++- src/statd/python/yanger/infix_firewall.py | 5 +- 7 files changed, 173 insertions(+), 61 deletions(-) diff --git a/board/common/rootfs/usr/bin/yorn b/board/common/rootfs/usr/bin/yorn index 60b1b37a7..cd4805e65 100755 --- a/board/common/rootfs/usr/bin/yorn +++ b/board/common/rootfs/usr/bin/yorn @@ -1,11 +1,18 @@ #!/bin/sh +opts="-n1" + +if [ "$1" = "-q" ]; then + opts="$opts -s" + shift +fi + Q=$@ /bin/echo -n "$Q, are you sure (y/N)? " -read -n1 yorn +read $opts yorn echo -if [ x$yorn != "xy" ] && [ x$yorn != "xY" ]; then +if [ "x$yorn" != "xy" ] && [ "x$yorn" != "xY" ]; then echo "OK, aborting." exit 1 fi diff --git a/src/confd/bin/firewall b/src/confd/bin/firewall index 7958b7eb4..6c6f9ef07 100755 --- a/src/confd/bin/firewall +++ b/src/confd/bin/firewall @@ -6,6 +6,13 @@ DEST="org.fedoraproject.FirewallD1" OBJECT="/org/fedoraproject/FirewallD1" INTERFACE="org.fedoraproject.FirewallD1" +VERBOSE=0 + +print() { + if [ "$VERBOSE" -eq 1 ]; then + printf '%s\n' "$*" + fi +} check_firewalld() { @@ -23,7 +30,7 @@ call_reload() if [ $ret -eq 0 ] && [ "$output" = "()" ]; then return 0 else - echo "Error: Reload method failed (exit code: $ret, output: '$output')" >&2 + print "Error: Reload method failed (exit code: $ret, output: '$output')" >&2 return 1 fi } @@ -40,7 +47,7 @@ wait_for_reload() fi done - echo "Timeout waiting for firewall reload completion" >&2 + print "Timeout waiting for firewall reload completion" >&2 return 1 } @@ -76,7 +83,7 @@ panic_on() if ! gdbus call --system --dest "$DEST" --object-path "$OBJECT" \ --method "$INTERFACE.enablePanicMode" >/dev/null 2>&1; then - echo "Error: Failed to activate lockdown mode" >&2 + print "Error: Failed to activate lockdown mode" >&2 return 1 fi @@ -89,7 +96,7 @@ panic_off() if ! gdbus call --system --dest "$DEST" --object-path "$OBJECT" \ --method "$INTERFACE.disablePanicMode" >/dev/null 2>&1; then - echo "Error: Failed to deactivate lockdown mode" >&2 + print "Error: Failed to deactivate lockdown mode" >&2 return 1 fi @@ -99,43 +106,39 @@ panic_off() panic_status() { if is_panic_enabled; then - echo "Lockdown mode: ACTIVE" - else - echo "Lockdown mode: INACTIVE" + print "Lockdown mode: ACTIVE" + return 0 fi - return 0 + + print "Lockdown mode: INACTIVE" + return 1 } show_status() { echo "=== Firewall Status ===" - # Basic status - echo "Service Status:" if check_firewalld; then - echo " Firewalld: RUNNING" + echo " Firewalld : RUNNING" else - echo " Firewalld: NOT RUNNING" + echo "Firewalld NOT RUNNING" return 1 fi - # Query lockdown status using helper if is_panic_enabled; then - lockdown_upper="TRUE" + panic="on" else - lockdown_upper="FALSE" + panic="off" fi - echo " Lockdown Mode: $lockdown_upper" + echo " Lockdown Mode : $panic" - # Default zone and logging using generic helper default_zone=$(gdbus_call "getDefaultZone" | sed "s/[']//g") logging=$(gdbus_call "getLogDenied" | sed "s/[']//g") - echo " Default Zone: $default_zone" - echo " Log Denied: $logging" + echo " Default Zone : $default_zone" + echo " Log Denied : $logging" echo - # Active zones echo "=== Active Zones ===" zones_output=$(gdbus call --system --dest "$DEST" --object-path "$OBJECT" \ --method org.fedoraproject.FirewallD1.zone.getActiveZones 2>/dev/null | \ @@ -148,7 +151,6 @@ show_status() fi echo - # Services echo "=== Available Services ===" services=$(gdbus call --system --dest "$DEST" --object-path "$OBJECT" \ --method "$INTERFACE.listServices" 2>/dev/null | \ @@ -165,7 +167,6 @@ show_status() fi echo - # Policies echo "=== Policies ===" policies=$(gdbus call --system --dest "$DEST" --object-path "$OBJECT" \ --method org.fedoraproject.FirewallD1.policy.getPolicies 2>/dev/null | \ @@ -179,52 +180,36 @@ show_status() echo "$policies" | jq -r '.[]' 2>/dev/null | while read policy_name; do echo " Policy: $policy_name" - # Get policy details policy_settings=$(gdbus call --system --dest "$DEST" --object-path "$OBJECT" \ --method org.fedoraproject.FirewallD1.policy.getPolicySettings \ "$policy_name" 2>/dev/null) if [ -n "$policy_settings" ] && [ "${policy_settings#*Error}" = "$policy_settings" ]; then - # Parse D-Bus variant format - - # Extract target target=$(echo "$policy_settings" | grep -o "'target': <'[^']*'" | cut -d"'" -f4) - - # Extract description description=$(echo "$policy_settings" | grep -o "'description': <'[^']*'" | cut -d"'" -f4) - - # Extract masquerade masquerade=$(echo "$policy_settings" | grep -o "'masquerade': <[^>]*>" | sed "s/.*<\([^>]*\)>.*/\1/") - - # Extract priority priority=$(echo "$policy_settings" | grep -o "'priority': <[^>]*>" | sed "s/.*<\([^>]*\)>.*/\1/") - - # Extract ingress zones - handle the specific format from your example if echo "$policy_settings" | grep -q "'ingress_zones'"; then # Match: 'ingress_zones': <['internal']> or 'ingress_zones': <['dmz', 'internal']> ingress_zones=$(echo "$policy_settings" | grep -o "'ingress_zones': <\[[^]]*\]>" | sed "s/'ingress_zones': <\[//; s/\]>//; s/'//g" | sed 's/, */, /g') fi - # Extract egress zones - handle the specific format if echo "$policy_settings" | grep -q "'egress_zones'"; then # Match: 'egress_zones': <['external']> or 'egress_zones': <['HOST']> egress_zones=$(echo "$policy_settings" | grep -o "'egress_zones': <\[[^]]*\]>" | sed "s/'egress_zones': <\[//; s/\]>//; s/'//g" | sed 's/, */, /g') fi - # Extract rich rules - handle the complex nested quotes carefully if echo "$policy_settings" | grep -q "'rich_rules'"; then - # Extract the rich rules array content rich_rules=$(echo "$policy_settings" | grep -o "'rich_rules': <\[[^]]*\]>" | sed "s/'rich_rules': <\[//; s/\]>//") fi - echo " Target: ${target:-unknown}" - echo " Description: ${description:-none}" - echo " Priority: ${priority:-unknown}" - echo " Ingress: ${ingress_zones:-none}" - echo " Egress: ${egress_zones:-none}" - echo " Masquerade: ${masquerade:-false}" + echo " Target : ${target:-unknown}" + echo " Description: ${description:-none}" + echo " Priority : ${priority:-unknown}" + echo " Ingress : ${ingress_zones:-none}" + echo " Egress : ${egress_zones:-none}" + echo " Masquerade : ${masquerade:-false}" - # Parse and display rich rules if [ -n "$rich_rules" ] && [ "$rich_rules" != "" ]; then rule_count=$(echo "$rich_rules" | grep -o "'" | wc -l) rule_count=$((rule_count / 2)) @@ -269,6 +254,7 @@ Usage: $0 [OPTIONS] COMMAND OPTIONS: --wait SEC Wait for reload completion signal (use with reload command) + -v, --verbose Enable verbose output for error messages and status -h, --help Show this help message COMMANDS: @@ -289,26 +275,27 @@ This tool uses the FirewallD D-Bus API directly for reliable operation. EOF } -# Main logic main() { wait_timeout="" - # Parse options using getopt - if ! parsed_args=$(getopt -o h --long wait:,help -- "$@"); then + if ! parsed_args=$(getopt -o hv --long wait:,help,verbose -- "$@"); then echo "Error parsing options" >&2 exit 1 fi eval set -- "$parsed_args" - # Process options while true; do case "$1" in --wait) wait_timeout="$2" shift 2 ;; + -v|--verbose) + VERBOSE=1 + shift + ;; -h|--help) show_help exit 0 @@ -324,31 +311,27 @@ main() esac done - # Process command case "${1:-}" in reload) if ! check_firewalld; then - echo "Error: firewalld is not running or not accessible" >&2 + echo "Error: firewalld is not running or does not respond!" >&2 exit 1 fi - # Call reload first if ! call_reload; then exit 1 fi - # Wait if --wait option was provided if [ -n "$wait_timeout" ]; then if ! wait_for_reload "$wait_timeout"; then echo "Firewall reload timed out" >&2 exit 1 fi fi - # Success - silent on UNIX principle ;; panic) if ! check_firewalld; then - echo "Error: firewalld is not running or not accessible" >&2 + echo "Error: firewalld is not running or does not respond" >&2 exit 1 fi diff --git a/src/confd/src/infix-firewall.c b/src/confd/src/infix-firewall.c index d0b757c6f..b88321563 100644 --- a/src/confd/src/infix-firewall.c +++ b/src/confd/src/infix-firewall.c @@ -559,12 +559,30 @@ static int cand(sr_session_ctx_t *session, uint32_t sub_id, const char *module, 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) +{ + const char *operation = input->data.string_val; + int rc; + + DEBUG("lockdown-mode: operation = %s", operation); + rc = systemf("firewall panic %s", strcmp(operation, "now") ? "off" : "on"); + if (rc) { + ERROR("lockdown-mode: firewall command failed with exit code %d", rc); + return SR_ERR_OPERATION_FAILED; + } + + return SR_ERR_OK; +} + int infix_firewall_init(struct confd *confd) { int rc; REGISTER_CHANGE(confd->session, MODULE, XPATH, 0, change, confd, &confd->sub); REGISTER_CHANGE(confd->cand, MODULE, XPATH "//.", SR_SUBSCR_UPDATE, cand, confd, &confd->sub); + REGISTER_RPC(confd->session, XPATH "/lockdown-mode", lockdown, NULL, &confd->sub); return SR_ERR_OK; fail: diff --git a/src/confd/yang/confd/infix-firewall.yang b/src/confd/yang/confd/infix-firewall.yang index 28dfa6343..96285eb92 100644 --- a/src/confd/yang/confd/infix-firewall.yang +++ b/src/confd/yang/confd/infix-firewall.yang @@ -181,6 +181,12 @@ module infix-firewall { type boolean; } */ + leaf lockdown { + description "Current state of emergency lockdown mode."; + config false; + type boolean; + } + leaf default { description "Default zone for interfaces. @@ -414,5 +420,62 @@ module infix-firewall { } */ } + + action lockdown-mode { + description "Emergency lockdown mode blocks all network traffic. + + This action is effectively a kill switch for all network + connections, immediately dropping all incoming and outgoing + packets and terminating existing sessions. It is intended for + emergency situations such as active security breaches where + immediate network isolation is required. + + WARNING: Activating lockdown mode will sever all remote + connections including SSH sessions. Physical console access + will be required to deactivate lockdown mode and restore + normal network operations. + + Implementation uses firewalld panic mode under the hood to + achieve complete traffic blocking at the netfilter level."; + input { + leaf operation { + description "Lockdown operation to perform"; + type enumeration { + enum now { + description "Enable lockdown mode immediately - block all traffic"; + } + enum cancel { + description "Cancel lockdown mode - restore normal operation"; + } + } + mandatory true; + } + } + /* ADVANCED FEATURE: Skipped for now, please see node lockdown-state, below. + output { + leaf status { + description "Current lockdown mode status after operation"; + type enumeration { + enum active { + description "Lockdown mode is currently active"; + } + enum inactive { + description "Lockdown mode is currently inactive"; + } + enum error { + description "Operation failed"; + } + } + } + + leaf message { + description "Additional status or error information"; + config false; + type string; + } + } + */ + + } } } diff --git a/src/klish-plugin-infix/xml/infix.xml b/src/klish-plugin-infix/xml/infix.xml index 2a11c98ee..728ed6737 100644 --- a/src/klish-plugin-infix/xml/infix.xml +++ b/src/klish-plugin-infix/xml/infix.xml @@ -552,6 +552,25 @@ /ietf-factory-default:factory-reset + + + + + now + cancel + + + + if [ "${KLISH_PARAM_operation}" = "now" ]; then + if ! firewall panic status; then + /bin/yorn -q "WARNING: This will block ALL network traffic and sever existing connections" + fi + fi + + /infix-firewall:firewall/lockdown-mode + + + diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index 600b3d225..6ea4cb29b 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -166,6 +166,14 @@ def bright_green(txt): def yellow(txt): return Decore.decorate("33", txt, "39") + @staticmethod + def bold_yellow(txt): + return Decore.decorate("1;33", txt, "0") + + @staticmethod + def flashing_red(txt): + return Decore.decorate("5;31", txt, "0") + @staticmethod def underline(txt): return Decore.decorate("4", txt, "24") @@ -1616,8 +1624,22 @@ def show_firewall(json): print("Firewall disabled.") return - # Adjust 20 + 8, where 8 is len(bold) + len(restore) - print(f"{Decore.bold('Firewall'):<28}: enabled") # TODO: 'pause' RPC state + # Build firewall status with contextual alerts + lockdown_state = fw.get('lockdown', False) + logging_enabled = fw.get('logging', 'off') != 'off' + + firewall_status = "active" + if lockdown_state: # Lockdown mode takes priority + firewall_status += f" [ {Decore.flashing_red('LOCKDOWN MODE')} ]" + elif logging_enabled: + firewall_status += f" [ {Decore.bold_yellow('MONITORING')} ]" + + # Adjust 20 + 8, where 8 is len(bold) + print(f"{Decore.bold('Firewall'):<28}: {firewall_status}") + + lockdown_display = "active" if lockdown_state else "inactive" + print(f"{Decore.bold('Lockdown mode'):<28}: {lockdown_display}") + print(f"{Decore.bold('Default zone'):<28}: {fw.get('default', 'unknown')}") print(f"{Decore.bold('Log denied traffic'):<28}: {fw.get('logging', 'off')}") @@ -1633,7 +1655,6 @@ def show_firewall(json): def ip_in_network(ip_addr, network): """Check if an IP address falls within a CIDR network""" try: - import ipaddress ip = ipaddress.ip_address(ip_addr) net = ipaddress.ip_network(network, strict=False) return ip in net diff --git a/src/statd/python/yanger/infix_firewall.py b/src/statd/python/yanger/infix_firewall.py index 9b2af2d39..7dbb7efab 100644 --- a/src/statd/python/yanger/infix_firewall.py +++ b/src/statd/python/yanger/infix_firewall.py @@ -15,7 +15,7 @@ def get_interface(interface="org.fedoraproject.FirewallD1"): bus = dbus.SystemBus() obj = bus.get_object("org.fedoraproject.FirewallD1", "/org/fedoraproject/FirewallD1") - return dbus.Interface(obj, interface) + return dbus.Interface(obj, dbus_interface=interface) except dbus.exceptions.DBusException as e: common.LOG.warning("Failed to connect to firewalld D-Bus: %s", e) @@ -258,7 +258,8 @@ def operational(): data = { "infix-firewall:firewall": { "default": fw.getDefaultZone(), - "logging": fw.getLogDenied() + "logging": fw.getLogDenied(), + "lockdown": bool(fw.queryPanicMode()) } } From a6db4f13b30a171324feca430af164d8e9fcec87 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 19 Aug 2025 15:55:30 +0200 Subject: [PATCH 34/55] confd: add immutable flag (read-only) for built-in policies Prepared also for zones, which we don't have any yet. Signed-off-by: Joachim Wiberg --- doc/TODO.org | 4 ++-- package/confd/confd.mk | 22 ++++++++++++++++++- src/confd/yang/confd/infix-firewall.yang | 12 +++++++++++ src/statd/python/cli_pretty/cli_pretty.py | 26 +++++++++++++++++------ src/statd/python/yanger/infix_firewall.py | 6 ++++++ 5 files changed, 60 insertions(+), 10 deletions(-) diff --git a/doc/TODO.org b/doc/TODO.org index fc6e7859b..e7624632c 100644 --- a/doc/TODO.org +++ b/doc/TODO.org @@ -3,7 +3,7 @@ ports and changes to enslavement, so regenerate every time is a must! - [X] Add missing upper to port forward since port ranges are supported, see services! - [X] =[do] show firewall= not available yet -- [ ] Add RPC to pause firewall using =firewall-cmd --panic-on= and restart +- [X] Add RPC to pause firewall using =firewall-cmd --panic-on= and restart firewall again with =firewall-cmd --panic-off=. The current state can be queried using =firewall-cmd --query-panic=, which returns =yes= - [X] Remove debug log messages! @@ -17,7 +17,7 @@ - [X] If forwarding is disabled in a zone then the zone matrix should show deny for the same zone-to-zone communication - [X] We should show the implicit rules for communicating with the HOST -- [ ] Investigate "padlock" on built-in policys (and zones?) and expose more? +- [X] Investigate "padlock" on built-in policys (and zones?) and expose more? - [ ] Document established,related somewhere, fixed/padlocked policy? Also, document why this is a good idea to always have enabled. See RH docs. - [ ] Podman published ports, diff --git a/package/confd/confd.mk b/package/confd/confd.mk index 7694cdd36..1560d367a 100644 --- a/package/confd/confd.mk +++ b/package/confd/confd.mk @@ -15,6 +15,8 @@ CONFD_AUTORECONF = YES CONFD_CONF_OPTS += --disable-silent-rules --with-crypt=$(BR2_PACKAGE_CONFD_DEFAULT_CRYPT) CONFD_SYSREPO_SHM_PREFIX = sr_buildroot$(subst /,_,$(CONFIG_DIR))_confd CONFD_FIREWALL_SERVICES_YANG = $(CONFD_SRCDIR)/yang/confd/infix-firewall-services.yang +CONFD_FIREWALL_XML_FILES="$(TARGET_DIR)/usr/lib/firewalld/policies/*.xml \ + $(TARGET_DIR)/usr/lib/firewalld/zones/*.xml" define CONFD_CONF_ENV CFLAGS="$(INFIX_CFLAGS)" @@ -135,7 +137,7 @@ define CONFD_CLEANUP done; \ if [ $$MISSING -eq 1 ]; then \ exit 1; \ - fi; \ + fi; \ cd $(TARGET_DIR)/usr/lib/firewalld/services/; \ for xmlfile in *.xml; do \ service=$${xmlfile%.xml}; \ @@ -143,6 +145,24 @@ define CONFD_CLEANUP rm "$$xmlfile"; \ fi; \ done + for xmlfile in $$CONFD_FIREWALL_XML_FILES; do \ + [ -f "$$xmlfile" ] || continue; \ + if grep -q "(immutable)" "$$xmlfile"; then \ + continue; \ + fi; \ + if grep -q '' "$$xmlfile"; then \ + sed -i 's|\(.*\)|\1 (immutable)|' \ + "$$xmlfile"; \ + else \ + if echo "$$xmlfile" | grep -q "/policies/"; then \ + sed -i 's|(immutable)\n&|' \ + "$$xmlfile"; \ + else \ + sed -i 's|(immutable)\n&|' \ + "$$xmlfile"; \ + fi; \ + fi; \ + done endef CONFD_PRE_BUILD_HOOKS += CONFD_EMPTY_SYSREPO CONFD_PRE_BUILD_HOOKS += CONFD_CLEANUP diff --git a/src/confd/yang/confd/infix-firewall.yang b/src/confd/yang/confd/infix-firewall.yang index 96285eb92..443565f9f 100644 --- a/src/confd/yang/confd/infix-firewall.yang +++ b/src/confd/yang/confd/infix-firewall.yang @@ -228,6 +228,12 @@ module infix-firewall { type ident; } + leaf immutable { + description "Indicates if this zone is read-only/system-defined and cannot be modified."; + config false; + type boolean; + } + leaf description { description "Free-form description of the zone."; type string; @@ -331,6 +337,12 @@ module infix-firewall { type ident; } + leaf immutable { + description "Indicates if this policy is read-only/system-defined and cannot be modified."; + config false; + type boolean; + } + leaf description { description "Free-form description of this policy's purpose and scope."; type string; diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index 6ea4cb29b..52a047049 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -101,6 +101,7 @@ class PadLldp: class PadFirewall: + zone_locked = 2 zone_name = 20 zone_action = 9 zone_interfaces = 30 @@ -114,6 +115,7 @@ class PadFirewall: zone_flow_policy = 20 zone_flow_services = 45 + policy_locked = 2 policy_name = 20 policy_action = 9 policy_ingress = 30 @@ -125,15 +127,15 @@ class PadFirewall: # Firewall log display formatting log_time = 16 # ISO format: MM-DD HH:MM:SS log_action = 7 # REJECT/DROP + small buffer - log_src = 25 # IPv6 addresses (shortened) or IPv4 - log_dst = 25 # IPv6 addresses (shortened) or IPv4 + log_src = 26 # IPv6 addresses (shortened) or IPv4 + log_dst = 26 # IPv6 addresses (shortened) or IPv4 log_proto = 6 # TCP/UDP/ICMP + small buffer log_port = 5 # Port numbers + small buffer @classmethod def table_width(cls): """Table width for zones/policies tables, used to center matrix""" - return cls.zone_name + cls.zone_action + cls.zone_interfaces \ + return cls.zone_locked + cls.zone_name + cls.zone_action + cls.zone_interfaces \ + cls.zone_services @@ -2020,7 +2022,8 @@ def show_firewall_zone(json, zone_name=None): f"{policy_name:<{PadFirewall.zone_flow_policy}}" f"{services_display}") else: - hdr = (f"{'NAME':<{PadFirewall.zone_name}}" + hdr = (f"{'':<{PadFirewall.zone_locked}}" + f"{'NAME':<{PadFirewall.zone_name}}" f"{'ACTION':<{PadFirewall.zone_action}}" f"{'INTERFACES':<{PadFirewall.zone_interfaces}}" f"{'SERVICES':<{PadFirewall.zone_services}}") @@ -2039,7 +2042,11 @@ def show_firewall_zone(json, zone_name=None): else: services = "(none)" - print(f"{name:<{PadFirewall.zone_name}}" + immutable = zone.get('immutable', False) + locked = "⚷" if immutable else " " + + print(f"{locked:<{PadFirewall.zone_locked}}" + f"{name:<{PadFirewall.zone_name}}" f"{action:<{PadFirewall.zone_action}}" f"{interfaces:<{PadFirewall.zone_interfaces}}" f"{services}") @@ -2075,7 +2082,8 @@ def show_firewall_policy(json, policy_name=None): print(f"{'masquerade':<20}: {masquerade}") print(f"{'services':<20}: {services_display}") else: - hdr = (f"{'NAME':<{PadFirewall.policy_name}}" + hdr = (f"{'':<{PadFirewall.policy_locked}}" + f"{'NAME':<{PadFirewall.policy_name}}" f"{'ACTION':<{PadFirewall.policy_action}}" f"{'INGRESS':<{PadFirewall.policy_ingress}}" f"{'EGRESS':<{PadFirewall.policy_egress}}") @@ -2087,7 +2095,11 @@ def show_firewall_policy(json, policy_name=None): egress = ", ".join(policy.get('egress', [])) action = policy.get('action', 'reject') - print(f"{name:<{PadFirewall.policy_name}}" + immutable = policy.get('immutable', False) + locked = "⚷" if immutable else " " + + print(f"{locked:<{PadFirewall.policy_locked}}" + f"{name:<{PadFirewall.policy_name}}" f"{action:<{PadFirewall.policy_action}}" f"{ingress:<{PadFirewall.policy_ingress}}" f"{egress:<{PadFirewall.policy_egress}}") diff --git a/src/statd/python/yanger/infix_firewall.py b/src/statd/python/yanger/infix_firewall.py index 7dbb7efab..1ac9d0336 100644 --- a/src/statd/python/yanger/infix_firewall.py +++ b/src/statd/python/yanger/infix_firewall.py @@ -54,6 +54,9 @@ def get_zone_data(fw, name): zone["forwarding"] = bool(settings.get('forward', 0)) zone["description"] = settings.get('description', 0) + short = settings.get('short', '') + zone["immutable"] = bool(short and "(immutable)" in short) + forwards = settings.get('forward_ports', []) for fwd in forwards: try: @@ -157,6 +160,9 @@ def get_policy_data(fw, name): if description: policy["description"] = description + short = settings.get('short', '') + policy["immutable"] = bool(short and "(immutable)" in short) + ingress = settings.get('ingress_zones', []) if ingress: policy["ingress"] = list(ingress) From f2ee467881e60a8af960e4e101dc65d86ff87c36 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 21 Aug 2025 11:27:27 +0200 Subject: [PATCH 35/55] confd: add services (xml+enums) for netconf and restconf Signed-off-by: Joachim Wiberg --- package/confd/confd.mk | 5 +++++ package/confd/netconf.xml | 11 +++++++++++ package/confd/restconf.xml | 12 ++++++++++++ src/confd/yang/confd/infix-firewall-services.yang | 6 ++++++ 4 files changed, 34 insertions(+) create mode 100644 package/confd/netconf.xml create mode 100644 package/confd/restconf.xml diff --git a/package/confd/confd.mk b/package/confd/confd.mk index 1560d367a..4389b4d06 100644 --- a/package/confd/confd.mk +++ b/package/confd/confd.mk @@ -122,6 +122,11 @@ define CONFD_CLEANUP mkdir -p $(TARGET_DIR)/etc/firewalld/policies mkdir -p $(TARGET_DIR)/etc/firewalld/services touch $(TARGET_DIR)/etc/firewalld/firewalld.conf + mkdir -p $(TARGET_DIR)/usr/lib/firewalld/services + cp $(CONFD_PKGDIR)/netconf.xml $(TARGET_DIR)/usr/lib/firewalld/services/ + cp $(CONFD_PKGDIR)/restconf.xml $(TARGET_DIR)/usr/lib/firewalld/services/ + # Find all pre-defined services in our YANG services model, + # drop firewalld service.xml that are *not* enumerated. if [ ! -f "$(CONFD_FIREWALL_SERVICES_YANG)" ]; then \ echo "ERROR: $(CONFD_FIREWALL_SERVICES_YANG) not found"; \ exit 1; \ diff --git a/package/confd/netconf.xml b/package/confd/netconf.xml new file mode 100644 index 000000000..3a4039724 --- /dev/null +++ b/package/confd/netconf.xml @@ -0,0 +1,11 @@ + + + NETCONF + + NETCONF (Network Configuration Protocol) is a protocol for configuration + and monitoring of networked devices. Essentially it can be seen as XML + over SSH, for configuration and state/status, it also support RPC calls + (Remote Procedure Call), e.g., set date-time or reboot device. + + + diff --git a/package/confd/restconf.xml b/package/confd/restconf.xml new file mode 100644 index 000000000..205e9fc6c --- /dev/null +++ b/package/confd/restconf.xml @@ -0,0 +1,12 @@ + + + RESTCONF + + RESTCONF (RESTful Network Configuration Protocol) is a JSON over + HTTP-based protocol that provides a RESTful API for configuration + and operational data, as well as RPCs. Like NETCONF, but it can + be managed using only curl. + + + + diff --git a/src/confd/yang/confd/infix-firewall-services.yang b/src/confd/yang/confd/infix-firewall-services.yang index 3a49f4f7e..c95589edc 100644 --- a/src/confd/yang/confd/infix-firewall-services.yang +++ b/src/confd/yang/confd/infix-firewall-services.yang @@ -72,6 +72,9 @@ module infix-firewall-services { enum "netbios-ns" { description "137/udp — NetBIOS Name Service for Windows networking"; } + enum "netconf" { + description "830/tcp — Network Configuration Protocol for network device management"; + } enum "nfs" { description "2049/tcp+udp — Network File System for distributed file sharing"; } @@ -96,6 +99,9 @@ module infix-firewall-services { enum "rdp" { description "3389/tcp — Remote Desktop Protocol for Windows remote access"; } + enum "restconf" { + description "443/tcp — RESTful Network Configuration Protocol for HTTP-based network management"; + } enum "samba" { description "445/tcp — Windows file and printer sharing"; } From 0b9d7e1f501cadb3e6c84e1ede27999c420e0899 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 22 Aug 2025 09:00:52 +0200 Subject: [PATCH 36/55] confd: clean up firewalld config files when disabling firewall Signed-off-by: Joachim Wiberg --- src/confd/src/infix-firewall.c | 46 +++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/src/confd/src/infix-firewall.c b/src/confd/src/infix-firewall.c index b88321563..789fec20a 100644 --- a/src/confd/src/infix-firewall.c +++ b/src/confd/src/infix-firewall.c @@ -4,8 +4,8 @@ #include #include #include -#include #include +#include #include #include @@ -138,6 +138,43 @@ static int delete_file(const char *dir, const char *name) return SR_ERR_OK; } +static int cleanup_files(void) +{ + const char *dirs[] = { + FIREWALLD_ZONES_DIR, + FIREWALLD_SERVICES_DIR, + FIREWALLD_POLICIES_DIR, + NULL + }; + + if (erasef(FIREWALLD_CONF) && errno != ENOENT) + ERRNO("Failed removing %s: %s", FIREWALLD_CONF, strerror(errno)); + + for (int i = 0; dirs[i]; i++) { + struct dirent *entry; + DIR *d; + + d = opendir(dirs[i]); + if (!d) { + DEBUG("Directory %s does not exist, skipping cleanup", dirs[i]); + continue; + } + + while ((entry = readdir(d)) != NULL) { + if (entry->d_type != DT_REG) + continue; + + DEBUG("Removing firewalld file: %s/%s", dirs[i], entry->d_name); + if (erasef("%s/%s", dirs[i], entry->d_name) && errno != ENOENT) + ERRNO("Failed removing %s/%s: %s", dirs[i], entry->d_name, strerror(errno)); + } + + closedir(d); + } + + return SR_ERR_OK; +} + static int generate_zone(struct lyd_node *cfg, const char *name, char **ifaces) { const char *action, *desc; @@ -417,8 +454,11 @@ static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module if (!diff) goto err_release_data; - if (!global) + if (!global) { + /* Firewall is disabled - clean up all firewalld configuration files */ + cleanup_files(); goto done; + } if (lydx_get_descendant(diff, "firewall", "default", NULL) || lydx_get_descendant(diff, "firewall", "logging", NULL)) { @@ -431,7 +471,7 @@ static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module const char *default_zone = lydx_get_cattr(global, "default"); struct lyd_node *list, *node; - /* First, handle deletions by removing files */ + /* First, handle explicit deletions by removing files */ list = lydx_get_descendant(diff, "firewall", "zone", NULL); LYX_LIST_FOR_EACH(list, node, "zone") { if (lydx_get_op(node) == LYDX_OP_DELETE) From 9afc793080f71d284643ff4ec73d0339f261cc79 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 21 Aug 2025 11:29:51 +0200 Subject: [PATCH 37/55] Update firewall TODOs Signed-off-by: Joachim Wiberg --- doc/TODO.org | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/doc/TODO.org b/doc/TODO.org index e7624632c..b4ccdb2a7 100644 --- a/doc/TODO.org +++ b/doc/TODO.org @@ -22,6 +22,11 @@ document why this is a good idea to always have enabled. See RH docs. - [ ] Podman published ports, - [ ] Software fastpath +- [ ] Allow overriding/editing immutable policies and zones +- [ ] Add tests: basic (end device), wan-lan, wan-lan-dmz, hammer (stress) +- [ ] Add documentation + - See + - Add some tool tips: nc, nmap, ping, and socat to stress the firewall * TODO doc: User Guide From 4544fc17559fc80e49d0fb499c4c5da3a32d820c Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 21 Aug 2025 17:57:14 +0200 Subject: [PATCH 38/55] test/infamy: add IPv6 versions of must_reach/must_not_reach Signed-off-by: Joachim Wiberg --- test/infamy/netns.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/infamy/netns.py b/test/infamy/netns.py index 317e006db..2edb63cbb 100644 --- a/test/infamy/netns.py +++ b/test/infamy/netns.py @@ -248,6 +248,14 @@ def ping(self, daddr, id=None, timeout=None): stdout=subprocess.PIPE, stderr=subprocess.STDOUT, input=f"while :; do {ping} && break; done") + def ping6(self, daddr, timeout=None): + timeout = timeout if timeout else self.ping_timeout + ping6 = f"ping6 -c1 -w1 {daddr}" + + return self.run(["timeout", str(timeout), "/bin/sh"], text=True, check=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + input=f"while :; do {ping6} && break; done") + def must_reach(self, *args, **kwargs): self.ping(*args, **kwargs) @@ -259,6 +267,17 @@ def must_not_reach(self, *args, **kwargs): raise Exception(res) + def must_reach6(self, *args, **kwargs): + self.ping6(*args, **kwargs) + + def must_not_reach6(self, *args, **kwargs): + try: + res = self.ping6(*args, **kwargs) + except subprocess.CalledProcessError as e: + return + + raise Exception(res) + def must_receive(self, expr, ifname, timeout=None, must=True): timeout = timeout if timeout else self.ping_timeout From 8633b83305ddc1b46afe8b095987782bc8983ba4 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 22 Aug 2025 15:27:41 +0200 Subject: [PATCH 39/55] test: add nmap to Infamy container - Sort packages alphabetically - Add nmap for firewall tests Signed-off-by: Joachim Wiberg --- test/.env | 2 +- test/docker/Dockerfile | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/test/.env b/test/.env index 09bf096fb..962afdf37 100644 --- a/test/.env +++ b/test/.env @@ -2,7 +2,7 @@ # shellcheck disable=SC2034,SC2154 # Current container image -INFIX_TEST=ghcr.io/kernelkit/infix-test:2.4 +INFIX_TEST=ghcr.io/kernelkit/infix-test:2.5 ixdir=$(readlink -f "$testdir/..") logdir=$(readlink -f "$testdir/.log") diff --git a/test/docker/Dockerfile b/test/docker/Dockerfile index c9682dd0b..81a07e081 100644 --- a/test/docker/Dockerfile +++ b/test/docker/Dockerfile @@ -1,8 +1,12 @@ FROM alpine:3.18.0 +# NOTE: please add packages alphabetically! RUN apk add --no-cache \ busybox-extras \ + curl \ e2fsprogs \ + e2tools \ + ethtool \ fakeroot \ gcc \ git \ @@ -13,7 +17,10 @@ RUN apk add --no-cache \ libc-dev \ libyang-dev \ linux-headers \ + make \ + nmap \ openssh-client \ + openssl \ python3-dev \ qemu-img \ qemu-system-x86_64 \ @@ -22,12 +29,7 @@ RUN apk add --no-cache \ squashfs-tools \ sshpass \ tcpdump \ - tshark \ - openssl \ - curl \ - e2tools \ - make \ - ethtool + tshark ARG MTOOL_VERSION="3.0" RUN wget https://github.com/troglobit/mtools/releases/download/v3.0/mtools-$MTOOL_VERSION.tar.gz -O /tmp/mtools-$MTOOL_VERSION.tar.gz From 330f76e337eb1794aa578a2192b0e86707b13d0f Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 21 Aug 2025 11:32:02 +0200 Subject: [PATCH 40/55] test: new test, basic firewall zone verification Signed-off-by: Joachim Wiberg --- test/case/all.yaml | 3 + test/case/infix_firewall/Readme.adoc | 6 + test/case/infix_firewall/basic/Readme.adoc | 1 + test/case/infix_firewall/basic/basic.adoc | 48 ++++++ test/case/infix_firewall/basic/test.py | 159 +++++++++++++++++++ test/case/infix_firewall/basic/topology.dot | 23 +++ test/case/infix_firewall/basic/topology.svg | 43 +++++ test/case/infix_firewall/infix_firewall.yaml | 3 + test/infamy/__init__.py | 1 + test/infamy/portscanner.py | 153 ++++++++++++++++++ 10 files changed, 440 insertions(+) create mode 100644 test/case/infix_firewall/Readme.adoc create mode 120000 test/case/infix_firewall/basic/Readme.adoc create mode 100644 test/case/infix_firewall/basic/basic.adoc create mode 100755 test/case/infix_firewall/basic/test.py create mode 100644 test/case/infix_firewall/basic/topology.dot create mode 100644 test/case/infix_firewall/basic/topology.svg create mode 100644 test/case/infix_firewall/infix_firewall.yaml create mode 100644 test/infamy/portscanner.py diff --git a/test/case/all.yaml b/test/case/all.yaml index 3db38337e..2110283bd 100644 --- a/test/case/all.yaml +++ b/test/case/all.yaml @@ -35,5 +35,8 @@ - name: infix-services suite: infix_services/infix_services.yaml +- name: infix-firewall + suite: infix_firewall/infix_firewall.yaml + - name: use-cases suite: use_case/use_case.yml diff --git a/test/case/infix_firewall/Readme.adoc b/test/case/infix_firewall/Readme.adoc new file mode 100644 index 000000000..ed96063f8 --- /dev/null +++ b/test/case/infix_firewall/Readme.adoc @@ -0,0 +1,6 @@ +:testgroup: +== infix-firewall + +<<< + +include::basic/test.py[] \ No newline at end of file diff --git a/test/case/infix_firewall/basic/Readme.adoc b/test/case/infix_firewall/basic/Readme.adoc new file mode 120000 index 000000000..885dcc595 --- /dev/null +++ b/test/case/infix_firewall/basic/Readme.adoc @@ -0,0 +1 @@ +basic.adoc \ No newline at end of file diff --git a/test/case/infix_firewall/basic/basic.adoc b/test/case/infix_firewall/basic/basic.adoc new file mode 100644 index 000000000..dc77fcc41 --- /dev/null +++ b/test/case/infix_firewall/basic/basic.adoc @@ -0,0 +1,48 @@ +=== Basic firewall test for end devices +==== Description +Test a simple restrictive firewall configuration suitable for end devices +like laptops or phones on untrusted networks. + +The test verifies: +- Single zone configuration similar to firewalld's default "public" zone +- Zone "public" with action=drop for data interface +- Zone "mgmt" with action=accept for management interface (NETCONF/RESTCONF) +- Allowed services: SSH (port 22), DHCPv6-client +- Blocked: All other ports (HTTP, HTTPS, Telnet, etc.) + +Port scanning tests validate that: +- SSH access is allowed (for management) +- All non-essential services are blocked/filtered +- Firewall properly filters unwanted traffic + +Uses the infamy.PortScanner class with netcat to test port accessibility: +- Open: Port accessible (service running) +- Closed: Port not filtered but no service listening +- Filtered: Port blocked by firewall (timeout/drop) + +This provides suitable protection for end-device scenarios while +maintaining management access for testing. + +==== Topology +ifdef::topdoc[] +image::{topdoc}../../test/case/infix_firewall/basic/topology.svg[Basic firewall test for end devices topology] +endif::topdoc[] +ifndef::topdoc[] +ifdef::testgroup[] +image::basic/topology.svg[Basic firewall test for end devices topology] +endif::testgroup[] +ifndef::testgroup[] +image::topology.svg[Basic firewall test for end devices topology] +endif::testgroup[] +endif::topdoc[] +==== Test sequence +. Set up topology and attach to target +. Configure basic end-device firewall +. Verify ICMP is dropped +. Verify ICMPv6 is dropped +. Verify SSH port (22) is allowed +. Verify well-known ports are blocked except SSH + + +<<< + diff --git a/test/case/infix_firewall/basic/test.py b/test/case/infix_firewall/basic/test.py new file mode 100755 index 000000000..f698ce025 --- /dev/null +++ b/test/case/infix_firewall/basic/test.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +""" +Basic firewall test for end devices + +Test a simple restrictive firewall configuration suitable for end devices +like laptops or phones on untrusted networks. + +The test verifies: +- Single zone configuration similar to firewalld's default "public" zone +- Zone "public" with action=drop for data interface +- Zone "mgmt" with action=accept for management interface (NETCONF/RESTCONF) +- Allowed services: SSH (port 22), DHCPv6-client +- Blocked: All other ports (HTTP, HTTPS, Telnet, etc.) + +Port scanning tests validate that: +- SSH access is allowed (for management) +- All non-essential services are blocked/filtered +- Firewall properly filters unwanted traffic + +Uses the infamy.PortScanner class with netcat to test port accessibility: +- Open: Port accessible (service running) +- Closed: Port not filtered but no service listening +- Filtered: Port blocked by firewall (timeout/drop) + +This provides suitable protection for end-device scenarios while +maintaining management access for testing. +""" +from time import sleep +import infamy +from infamy.util import until + +# Some helper classes return extra debug info which this switch unlocks +DEBUG = False + + +def debug(msg): + """Debug messages not relevant for regular test output""" + if DEBUG: + print(msg) + +with infamy.Test() as test: + 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") + + # Get target IP for scanning + TARGET_IP = "192.168.1.1" + HOST_IP = "192.168.1.42" + + with test.step("Configure basic end-device firewall"): + target.put_config_dict("ietf-interfaces", { + "interfaces": { + "interface": [ + { + "name": data_if, + "enabled": True, + "ipv4": { + "address": [{ + "ip": TARGET_IP, + "prefix-length": 24 + }] + } + } + ] + } + }) + + # Configure firewall with management and public zones + target.put_config_dict("infix-firewall", { + "firewall": { + "default": "public", + "logging": "all", + "zone": [ + { + "name": "mgmt", + "description": "Management network - allow NETCONF/RESTCONF", + "action": "accept", + "interface": [mgmt_if], + "service": ["ssh", "netconf", "restconf"] + }, + { + "name": "public", + "description": "Public untrusted network - end device protection", + "action": "drop", + "interface": [data_if], + "service": ["ssh", "dhcpv6-client"] + } + ] + } + }) + + debug("Waiting for configuration to take ...") + sleep(1) + + # Verify firewall configuration + config = target.get_config_dict("/infix-firewall:firewall") + fw = config["firewall"] + + assert fw["default"] == "public" + assert len(fw["zone"]) == 2 + + # Check zones + zones = {z["name"]: z for z in fw["zone"]} + + # Verify management zone + mgmt_zone = zones["mgmt"] + assert mgmt_zone["action"] == "accept" + assert mgmt_if in mgmt_zone["interface"] + assert "ssh" in mgmt_zone["service"] + assert "netconf" in mgmt_zone["service"] + + # Verify public zone + public_zone = zones["public"] + assert public_zone["action"] == "drop" + assert data_if in public_zone["interface"] + assert "ssh" in public_zone["service"] + assert "dhcpv6-client" in public_zone["service"] + + with infamy.IsolatedMacVlan(host_data) as ns: + ns.addip(HOST_IP) + + with test.step("Verify ICMP is dropped"): + ns.must_not_reach(TARGET_IP, timeout=2) + + with test.step("Verify ICMPv6 is dropped"): + ns.must_not_reach6("fe80::1%iface", timeout=2) + + with test.step("Verify SSH port (22) is allowed"): + scanner = infamy.PortScanner(ns) + ssh_result = scanner.scan_port(TARGET_IP, 22, timeout=2) + assert ssh_result["status"] in ["open", "closed"], \ + f"SSH port should be allowed, got: {ssh_result['status']}" + + with test.step("Verify well-known ports are blocked except SSH"): + firewall = infamy.Firewall(ns, None) + allowed = [22] # Only SSH should be allowed + + ok, open_ports, filtered_ports = \ + firewall.verify_blocked(TARGET_IP, exempt=allowed) + + for port in open_ports: + print(f" ⚠ Unexpectedly open: {port}") + for port in filtered_ports: + print(f" ⚠ SSH blocked when it should be allowed: {port}") + + if not ok: + if open_ports: + print(f"Found unexpected open ports: {', '.join(open_ports)}") + test.fail() + if filtered_ports: + print(f"SSH port blocked when it should be allowed: {', '.join(filtered_ports)}") + test.fail() + else: + debug(" ✓ Firewall policy working correctly - only SSH allowed") + + test.succeed() diff --git a/test/case/infix_firewall/basic/topology.dot b/test/case/infix_firewall/basic/topology.dot new file mode 100644 index 000000000..6cc760fb9 --- /dev/null +++ b/test/case/infix_firewall/basic/topology.dot @@ -0,0 +1,23 @@ +graph "1x2" { + layout = "neato"; + overlap = false; + esep = "+80"; + + node [shape=record, fontname="DejaVu Sans Mono, Book"]; + edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"]; + + host [ + label="host | { mgmt | data }", + pos="1,1!", + requires="controller" + ]; + + target [ + label="{ mgmt | data } | target", + pos="3,1!", + requires="infix", + ]; + + host:mgmt -- target:mgmt [requires="mgmt", color="lightgray"] + host:data -- target:data [color=black, fontcolor=black, taillabel="192.168.1.42/24"] +} diff --git a/test/case/infix_firewall/basic/topology.svg b/test/case/infix_firewall/basic/topology.svg new file mode 100644 index 000000000..97adf98a3 --- /dev/null +++ b/test/case/infix_firewall/basic/topology.svg @@ -0,0 +1,43 @@ + + + + + + +1x2 + + + +host + +host + +mgmt + +data + + + +target + +mgmt + +data + +target + + + +host:mgmt--target:mgmt + + + + +host:data--target:data + +192.168.1.42/24 + + + diff --git a/test/case/infix_firewall/infix_firewall.yaml b/test/case/infix_firewall/infix_firewall.yaml new file mode 100644 index 000000000..28b4457c0 --- /dev/null +++ b/test/case/infix_firewall/infix_firewall.yaml @@ -0,0 +1,3 @@ +--- +- name: basic + case: basic/test.py diff --git a/test/infamy/__init__.py b/test/infamy/__init__.py index 4c44ec8c3..7d2f56e4c 100644 --- a/test/infamy/__init__.py +++ b/test/infamy/__init__.py @@ -6,6 +6,7 @@ from .env import test_argument from .furl import Furl from .netns import IsolatedMacVlan,IsolatedMacVlans +from .portscanner import PortScanner from .sniffer import Sniffer from .tap import Test from .util import parallel, until diff --git a/test/infamy/portscanner.py b/test/infamy/portscanner.py new file mode 100644 index 000000000..26f56349a --- /dev/null +++ b/test/infamy/portscanner.py @@ -0,0 +1,153 @@ +""" +Port scanning utilities + +Lightweight wrapper around nmap for automated firewall testing. +Supports: + +- Scanning individual TCP/UDP ports with detailed state detection +- Parallel scanning of multiple ports using threads +- Predefined set of common service ports for convenience +""" +import threading +from typing import List, Dict, Union, Tuple + + +class PortScanner: + """Simple port scanner using netcat for firewall testing""" + + # Well-known ports for testing common services + WELL_KNOWN_PORTS = [ + (22, "tcp", "ssh"), + (53, "udp", "dns"), + (67, "udp", "dhcp"), + (69, "udp", "tftp"), + (80, "tcp", "http"), + (443, "tcp", "https"), + (5353, "udp", "mdns"), + (7681, "tcp", "ttyd"), + (8080, "tcp", "http-alt"), + (8443, "tcp", "https-alt"), + (7, "tcp", "echo"), + (1234, "tcp", "test-mid"), + (9999, "tcp", "test-high"), + ] + + def __init__(self, netns=None): + """ + Initialize port scanner + Args: + netns: Network namespace object (IsolatedMacVlan) or netns name + """ + self.netns = netns + + def scan_port(self, host: str, port: int, protocol: str = "tcp", + timeout: int = 3) -> Dict[str, Union[str, bool]]: + """ + Scan a single port using nmap for accurate firewall state detection + Args: + host: Target hostname or IP address + port: Port number to scan + protocol: 'tcp' or 'udp' + timeout: Connection timeout in seconds + Returns: + Dict with keys: 'open' (bool), 'status' (str), 'response' (str) + """ + # Build optimized nmap command based on protocol + if protocol.lower() == "tcp": + proto = "-sT" + elif protocol.lower() == "udp": + proto = "-sU" + else: + raise ValueError(f"Unsupported protocol: {protocol}") + + cmd = f"nmap -n {proto} -Pn -p {port} --host-timeout={timeout} " \ + f"--min-rate=1000 --max-retries=1 --disable-arp-ping {host}" + result = self.netns.runsh(cmd) + + # Parse nmap output to determine port state + output = result.stdout + + # Look for the specific port line in nmap output + # Format: "PORT STATE SERVICE" or "22/tcp open ssh" + port_line = None + for line in output.split('\n'): + if f"{port}/" in line and protocol in line: + port_line = line.strip() + break + + if port_line: + if "open|filtered" in port_line: + # No ICMP port unreachable, likely firewall dropping silently + status = "open|filtered" + is_open = False + elif "closed|filtered" in port_line: + # No service running, or firewall dropping + status = "closed|filtered" + is_open = False + elif "open" in port_line: + status = "open" + is_open = True + elif "filtered" in port_line: + # Blocked/Rejected by firewall + status = "filtered" + is_open = False + elif "closed" in port_line: + # Port reachable but service not running + status = "closed" + is_open = False + else: + # Unknown state + status = "unknown" + is_open = False + else: + # No port line found - likely filtered or error + status = "filtered" + is_open = False + + return { + "open": is_open, + "status": status, + "response": output.strip() + } + + def scan_ports(self, host: str, + port_specs: List[Tuple[int, str, str]], + timeout: int = 3) -> List[Tuple[int, str, Dict]]: + """ + Scan multiple ports in parallel using threads + Args: + host: Target hostname or IP address + port_specs: List (port, protocol, name) e.g. + [(80, "tcp", "http"), (53, "udp", "dns")] + timeout: Connection timeout per port + Returns: + List of (port, name, result) scan results. + """ + results = [] + threads = [] + lock = threading.Lock() + + def scan_worker(port: int, protocol: str, name: str): + try: + result = self.scan_port(host, port, protocol, timeout) + with lock: + results.append((port, name, result)) + except Exception as e: + with lock: + results.append((port, name, { + "open": False, + "status": "error", + "response": str(e) + })) + + for port, protocol, name in port_specs: + thread = threading.Thread(target=scan_worker, args=(port, protocol, name)) + threads.append(thread) + thread.start() + + for thread in threads: + thread.join() + + # Sort results by port number for consistent output + results.sort(key=lambda x: x[0]) + return results From 8eb3f1b79bc02689849c94a1d777e833192a1b87 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 22 Aug 2025 07:19:25 +0200 Subject: [PATCH 41/55] test: new test, lan-wan gateway with snat Signed-off-by: Joachim Wiberg --- test/case/infix_firewall/Readme.adoc | 4 +- test/case/infix_firewall/infix_firewall.yaml | 2 + test/case/infix_firewall/lan-wan/Readme.adoc | 1 + .../infix_firewall/lan-wan/lan-to-wan.adoc | 49 +++++ test/case/infix_firewall/lan-wan/test.py | 204 ++++++++++++++++++ test/case/infix_firewall/lan-wan/topology.dot | 24 +++ test/case/infix_firewall/lan-wan/topology.svg | 53 +++++ test/infamy/__init__.py | 1 + test/infamy/firewall.py | 162 ++++++++++++++ 9 files changed, 499 insertions(+), 1 deletion(-) create mode 120000 test/case/infix_firewall/lan-wan/Readme.adoc create mode 100644 test/case/infix_firewall/lan-wan/lan-to-wan.adoc create mode 100755 test/case/infix_firewall/lan-wan/test.py create mode 100644 test/case/infix_firewall/lan-wan/topology.dot create mode 100644 test/case/infix_firewall/lan-wan/topology.svg create mode 100644 test/infamy/firewall.py diff --git a/test/case/infix_firewall/Readme.adoc b/test/case/infix_firewall/Readme.adoc index ed96063f8..fa39244ab 100644 --- a/test/case/infix_firewall/Readme.adoc +++ b/test/case/infix_firewall/Readme.adoc @@ -3,4 +3,6 @@ <<< -include::basic/test.py[] \ No newline at end of file +include::basic/test.py[] + +include::lan-wan/test.py[] diff --git a/test/case/infix_firewall/infix_firewall.yaml b/test/case/infix_firewall/infix_firewall.yaml index 28b4457c0..51b5e06f6 100644 --- a/test/case/infix_firewall/infix_firewall.yaml +++ b/test/case/infix_firewall/infix_firewall.yaml @@ -1,3 +1,5 @@ --- - name: basic case: basic/test.py +- name: lan-to-wan + case: lan-wan/test.py diff --git a/test/case/infix_firewall/lan-wan/Readme.adoc b/test/case/infix_firewall/lan-wan/Readme.adoc new file mode 120000 index 000000000..ec6162e27 --- /dev/null +++ b/test/case/infix_firewall/lan-wan/Readme.adoc @@ -0,0 +1 @@ +lan-to-wan.adoc \ No newline at end of file diff --git a/test/case/infix_firewall/lan-wan/lan-to-wan.adoc b/test/case/infix_firewall/lan-wan/lan-to-wan.adoc new file mode 100644 index 000000000..bfe1d2346 --- /dev/null +++ b/test/case/infix_firewall/lan-wan/lan-to-wan.adoc @@ -0,0 +1,49 @@ +=== LAN-WAN firewall test with masquerading +==== Description +Test a typical home/office router scenario where the target device acts as +a gateway with LAN-to-WAN traffic forwarding and masquerading (SNAT). + +Architecture: +- Target device = Gateway with firewall and NAT +- Test host has two interfaces: one LAN-side, one WAN-side (Internet) +- Test host's LAN interface acts as a client behind the router +- Test host's WAN interface acts as an Internet server/destination + +The test verifies: +- LAN zone with action=accept for internal traffic +- WAN zone with action=drop for external interface +- Policy to allow LAN-to-WAN forwarding with masquerading +- Interface forwarding enabled on router +- Outbound connectivity from LAN clients through WAN works +- Inbound unsolicited traffic from WAN is blocked +- Masquerading (source NAT) functions correctly + +This validates a typical home router protecting internal network while +allowing outbound internet access. + +==== Topology +ifdef::topdoc[] +image::{topdoc}../../test/case/infix_firewall/lan-wan/topology.svg[LAN-WAN firewall test with masquerading topology] +endif::topdoc[] +ifndef::topdoc[] +ifdef::testgroup[] +image::lan-wan/topology.svg[LAN-WAN firewall test with masquerading topology] +endif::testgroup[] +ifndef::testgroup[] +image::topology.svg[LAN-WAN firewall test with masquerading topology] +endif::testgroup[] +endif::topdoc[] +==== Test sequence +. Set up topology and attach to gateway +. Configure gateway with firewall and SNAT +. Verify LAN access to router +. Verify WAN access to router is blocked +. Verify LAN services accessibility +. Verify WAN blocks all well-known ports +. Verify LAN-to-WAN connectivity (outbound) +. Verify WAN-to-LAN blocking (inbound) +. Verify LAN-to-WAN masquerading + + +<<< + diff --git a/test/case/infix_firewall/lan-wan/test.py b/test/case/infix_firewall/lan-wan/test.py new file mode 100755 index 000000000..93ca54eea --- /dev/null +++ b/test/case/infix_firewall/lan-wan/test.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +""" +LAN-WAN firewall test with masquerading + +Test a typical home/office router scenario where the target device acts as +a gateway with LAN-to-WAN traffic forwarding and masquerading (SNAT). + +Architecture: +- Target device = Gateway with firewall and NAT +- Test host has two interfaces: one LAN-side, one WAN-side (Internet) +- Test host's LAN interface acts as a client behind the router +- Test host's WAN interface acts as an Internet server/destination + +The test verifies: +- LAN zone with action=accept for internal traffic +- WAN zone with action=drop for external interface +- Policy to allow LAN-to-WAN forwarding with masquerading +- Interface forwarding enabled on router +- Outbound connectivity from LAN clients through WAN works +- Inbound unsolicited traffic from WAN is blocked +- Masquerading (source NAT) functions correctly + +This validates a typical home router protecting internal network while +allowing outbound internet access. +""" +from time import sleep +import infamy +from infamy.util import until + +# Some helper classes return extra debug info which this switch unlocks +DEBUG = False + + +def debug(msg): + """Debug messages not relevant for regular test output""" + if DEBUG: + print(msg) + + +with infamy.Test() as test: + with test.step("Set up topology and attach to gateway"): + env = infamy.Env() + gateway = env.attach("gateway", "mgmt") + _, lan_if = env.ltop.xlate("gateway", "lan") + _, wan_if = env.ltop.xlate("gateway", "wan") + _, mgmt_if = env.ltop.xlate("gateway", "mgmt") + _, host_lan = env.ltop.xlate("host", "lan") # Host LAN-side interface + _, host_wan = env.ltop.xlate("host", "wan") # Host WAN-side interface + + LAN_NET = "192.168.1.0/24" + LAN_ROUTER_IP = "192.168.1.1" # Router's LAN interface + LAN_CLIENT_IP = "192.168.1.100" # Client on LAN side + + WAN_NET = "203.0.113.0/24" # RFC 5737 test network + WAN_ROUTER_IP = "203.0.113.1" # Router's WAN interface + WAN_SERVER_IP = "203.0.113.100" # Server on WAN side + + with test.step("Configure gateway with firewall and SNAT"): + gateway.put_config_dict("ietf-interfaces", { + "interfaces": { + "interface": [ + { + "name": lan_if, + "enabled": True, + "ipv4": { + "forwarding": True, + "address": [{ + "ip": LAN_ROUTER_IP, + "prefix-length": 24 + }] + } + }, + { + "name": wan_if, + "enabled": True, + "ipv4": { + "forwarding": True, + "address": [{ + "ip": WAN_ROUTER_IP, + "prefix-length": 24 + }] + } + } + ] + } + }) + + gateway.put_config_dict("infix-firewall", { + "firewall": { + "default": "wan", + "logging": "all", + "zone": [ + { + "name": "lan", + "description": "Internal LAN network - trusted", + "action": "accept", + "interface": [lan_if, mgmt_if], + "service": ["ssh", "dhcp", "dns"] + }, { + "name": "wan", + "description": "External WAN interface - untrusted", + "action": "drop", + "interface": [wan_if] + } + ], + "policy": [ + { + "name": "lan-to-wan", + "description": "Allow LAN to WAN traffic with SNAT", + "ingress": ["lan"], + "egress": ["wan"], + "action": "accept", + "masquerade": True + } + ] + } + }) + + debug("Waiting for router configuration to take effect...") + sleep(1) + + # Verify firewall configuration + config = gateway.get_config_dict("/infix-firewall:firewall") + fw = config["firewall"] + + assert fw["default"] == "wan" + assert len(fw["zone"]) == 2 + + # Check zones + zones = {z["name"]: z for z in fw["zone"]} + + # Verify LAN zone + lan_zone = zones["lan"] + assert lan_zone["action"] == "accept" + assert lan_if in lan_zone["interface"] + + # Verify WAN zone + wan_zone = zones["wan"] + assert wan_zone["action"] == "drop" + assert wan_if in wan_zone["interface"] + + # Verify policy + policies = {p["name"]: p for p in fw["policy"]} + lan_wan_policy = policies["lan-to-wan"] + assert lan_wan_policy["ingress"] == ["lan"] + assert lan_wan_policy["egress"] == ["wan"] + assert lan_wan_policy["action"] == "accept" + assert lan_wan_policy["masquerade"] is True + + with infamy.IsolatedMacVlan(host_lan) as lan_client: + lan_client.addip(LAN_CLIENT_IP) + lan_client.addroute("0.0.0.0", LAN_ROUTER_IP, prefix_length="0") + + with infamy.IsolatedMacVlan(host_wan) as wan_server: + wan_server.addip(WAN_SERVER_IP) + + with test.step("Verify LAN access to router"): + lan_client.must_reach(LAN_ROUTER_IP, timeout=3) + + with test.step("Verify WAN access to router is blocked"): + wan_server.must_not_reach(WAN_ROUTER_IP, timeout=3) + + with test.step("Verify LAN services accessibility"): + firewall = infamy.Firewall(lan_client, None) + # Services expected to be available on LAN + svc = [ + (22, "tcp", "ssh"), + (53, "udp", "dns"), + (67, "udp", "dhcp"), + ] + + ok, ports = firewall.verify_allowed(LAN_ROUTER_IP, svc) + if ok: + debug(" ✓ All LAN services are accessible") + else: + print(f" ⚠ Some LAN services are filtered: {', '.join(ports)}") + test.fail() + + with test.step("Verify WAN blocks all well-known ports"): + firewall = infamy.Firewall(wan_server, None) + + ok, ports, _ = firewall.verify_blocked(WAN_ROUTER_IP) + if ok: + debug(" ✓ All well-known ports are blocked from WAN") + else: + print(f" ⚠ Some ports are unexpectedly open from WAN: {', '.join(ports)}") + test.fail() + + with test.step("Verify LAN-to-WAN connectivity (outbound)"): + lan_client.must_reach(WAN_SERVER_IP, timeout=3) + + with test.step("Verify WAN-to-LAN blocking (inbound)"): + wan_server.must_not_reach(LAN_CLIENT_IP, timeout=3) + + with test.step("Verify LAN-to-WAN masquerading"): + firewall = infamy.Firewall(lan_client, wan_server) + ok, info = firewall.verify_snat(WAN_SERVER_IP, WAN_ROUTER_IP) + if ok: + debug(f" ✓ {info}") + else: + print(f" ⚠ {info}") + test.fail() + + test.succeed() diff --git a/test/case/infix_firewall/lan-wan/topology.dot b/test/case/infix_firewall/lan-wan/topology.dot new file mode 100644 index 000000000..064446923 --- /dev/null +++ b/test/case/infix_firewall/lan-wan/topology.dot @@ -0,0 +1,24 @@ +graph "1x3" { + layout = "neato"; + overlap = false; + esep = "+80"; + + node [shape=record, fontname="DejaVu Sans Mono, Book"]; + edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"]; + + host [ + label="host | { mgmt | lan | wan }", + pos="1,1!", + requires="controller" + ]; + + gateway [ + label="{ mgmt | lan | wan } | gateway", + pos="3,1!", + requires="infix", + ]; + + host:mgmt -- gateway:mgmt [requires="mgmt", color="lightgray"] + host:lan -- gateway:lan [color=black, fontcolor=black, taillabel="192.168.1.0/24"] + host:wan -- gateway:wan [color=red, fontcolor=red, taillabel="203.0.113.0/24"] +} diff --git a/test/case/infix_firewall/lan-wan/topology.svg b/test/case/infix_firewall/lan-wan/topology.svg new file mode 100644 index 000000000..e5d25cfaf --- /dev/null +++ b/test/case/infix_firewall/lan-wan/topology.svg @@ -0,0 +1,53 @@ + + + + + + +1x3 + + + +host + +host + +mgmt + +lan + +wan + + + +gateway + +mgmt + +lan + +wan + +gateway + + + +host:mgmt--gateway:mgmt + + + + +host:lan--gateway:lan + +192.168.1.0/24 + + + +host:wan--gateway:wan + +203.0.113.0/24 + + + diff --git a/test/infamy/__init__.py b/test/infamy/__init__.py index 7d2f56e4c..f5397d93c 100644 --- a/test/infamy/__init__.py +++ b/test/infamy/__init__.py @@ -4,6 +4,7 @@ from .env import Env from .env import ArgumentParser from .env import test_argument +from .firewall import Firewall from .furl import Furl from .netns import IsolatedMacVlan,IsolatedMacVlans from .portscanner import PortScanner diff --git a/test/infamy/firewall.py b/test/infamy/firewall.py new file mode 100644 index 000000000..efb0944ba --- /dev/null +++ b/test/infamy/firewall.py @@ -0,0 +1,162 @@ +""" +Firewall testing utilities + +Provides a helper class and supporting tools to validate firewall +behavior in automated tests. Supports: + +- SNAT verification by inspecting captured ICMP traffic +- Zone policy checks using targeted port scans +- Positive and negative policy validation (allowed vs. blocked ports) +""" +import time +from typing import Tuple, List +from .sniffer import Sniffer +from .portscanner import PortScanner + + +class Firewall: + """Specialized utilities for testing firewall functionality""" + + def __init__(self, source=None, dest=None): + """ + Initialize firewall tester + Args: + source: Source network namespace (for traffic generation) + dest: Destination network namespace (for traffic capture) + """ + self.srcns = source + self.dstns = dest + + def verify_snat(self, dest_ip: str, snat_ip: str, + timeout: int = 3) -> Tuple[bool, str]: + """ + Verify SNAT (masquerading) by analyzing source IP of ICMP traffic + + Args: + dest_ip: Destination IP address to ping + snat_ip: Expected source IP after SNAT (router's WAN IP) + timeout: Test timeout in seconds + + Returns: + Tuple of (snat_working: bool, details: str) + """ + + try: + sniffer = Sniffer(self.dstns, "icmp") + with sniffer: + time.sleep(0.5) + self.srcns.runsh(f"ping -c3 -W{timeout} {dest_ip}") + time.sleep(0.5) + + rc = sniffer.output() + packets = rc.stdout + if rc.returncode or not packets.strip(): + return False, "No packets captured — routing may be broken" + + lines = packets.strip().split('\n') + snat_ip_found = False + lan_ip_found = False + + for line in lines: + if not line.strip(): + continue + + # Check if we see the expected SNAT IP as source + if f"{snat_ip} > {dest_ip}" in line: + snat_ip_found = True + + # Check if we see any other source IP (SNAT not working) + if f"> {dest_ip}" in line and snat_ip not in line: + parts = line.split() + for part in parts: + if f"> {dest_ip}" in part: + src_ip = part.split('>')[0].strip() + if '.' in src_ip and src_ip != snat_ip: + lan_ip_found = True + break + + if snat_ip_found and not lan_ip_found: + return True, f"SNAT working: only traffic from {snat_ip}" + if lan_ip_found and not snat_ip_found: + return False, f"SNAT broken: LAN IPs visible, no {snat_ip}" + if snat_ip_found and lan_ip_found: + return False, f"SNAT broken: both {snat_ip} and LAN IPs on WAN" + + return False, f"Unclear SNAT status, see capture:\n{packets}" + + except Exception as e: + return False, f"SNAT verification failed with error: {e}" + + def verify_blocked(self, dest_ip: str, ports: List[Tuple[int, str, str]] = None, + exempt: List[int] = None, timeout: int = 3) -> Tuple[bool, List[str], List[str]]: + """ + Verify specified ports are blocked, with optional exceptions + Args: + dest_ip: Target hostname or IP address + ports: List of port tuples, defaults to + PortScanner.WELL_KNOWN_PORTS + exempt: List of ports that should be excempt + timeout: Connection timeout per port + Returns: + When exempt=None: Tuple of (all_blocked: bool, open_ports: List[str], []) + When exempt=[...]: Tuple of (policy_correct: bool, unexpected_open: List[str], + unexpected_filtered_allowed: List[str]) + """ + if ports is None: + ports = PortScanner.WELL_KNOWN_PORTS + + scanner = PortScanner(self.srcns) + results = scanner.scan_ports(dest_ip, ports, timeout) + + if exempt is None: + # Simple "all blocked" behavior - only "open" is bad + open_ports = [] + for port, name, result in results: + if result["status"] == "open": + open_ports.append(f"{name}({port})") + return len(open_ports) == 0, open_ports, [] + + unexpected_open = [] + unexpected_filtered_allowed = [] + + for port, name, result in results: + if port in exempt: + # This port should be allowed (not filtered by firewall) + status = result["status"] + if status in ["filtered", "open|filtered", "closed|filtered"]: + unexpected_filtered_allowed.append(f"{name}({port})") + else: + # This port should be blocked - only "open" is bad + if result["status"] == "open": + unexpected_open.append(f"{name}({port})") + + policy_correct = (len(unexpected_open) == 0 and + len(unexpected_filtered_allowed) == 0) + return policy_correct, unexpected_open, unexpected_filtered_allowed + + def verify_allowed(self, dest_ip: str, ports: List[Tuple[int, str, str]] = None, + timeout: int = 3) -> Tuple[bool, List[str]]: + """ + Verify specified ports are allowed (open or closed, not filtered) + Args: + dest_ip: Target hostname or IP address + ports: List of port tuples, defaults to + PortScanner.WELL_KNOWN_PORTS + timeout: Connection timeout per port + Returns: + Tuple of (all_allowed: bool, filtered_ports: List[str]) + """ + if ports is None: + ports = PortScanner.WELL_KNOWN_PORTS + + scanner = PortScanner(self.srcns) + results = scanner.scan_ports(dest_ip, ports, timeout) + filtered_ports = [] + + for port, name, result in results: + status = result["status"] + # Consider any form of filtering as "not allowed" + if status in ["filtered", "open|filtered", "closed|filtered"]: + filtered_ports.append(f"{name}({port})") + + return len(filtered_ports) == 0, filtered_ports From 04904c00df22f7d85a11e851e934b1680a05ade0 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 23 Aug 2025 06:06:46 +0200 Subject: [PATCH 42/55] test: new test, wan-dmz-lan firewall with snat and dnat Signed-off-by: Joachim Wiberg --- test/case/infix_firewall/Readme.adoc | 2 + test/case/infix_firewall/infix_firewall.yaml | 2 + .../infix_firewall/wan-dmz-lan/Readme.adoc | 1 + test/case/infix_firewall/wan-dmz-lan/test.py | 334 ++++++++++++++++++ .../infix_firewall/wan-dmz-lan/topology.dot | 25 ++ .../infix_firewall/wan-dmz-lan/topology.svg | 63 ++++ .../wan-dmz-lan/wan-dmz-lan.adoc | 53 +++ test/infamy/firewall.py | 42 +++ 8 files changed, 522 insertions(+) create mode 120000 test/case/infix_firewall/wan-dmz-lan/Readme.adoc create mode 100755 test/case/infix_firewall/wan-dmz-lan/test.py create mode 100644 test/case/infix_firewall/wan-dmz-lan/topology.dot create mode 100644 test/case/infix_firewall/wan-dmz-lan/topology.svg create mode 100644 test/case/infix_firewall/wan-dmz-lan/wan-dmz-lan.adoc diff --git a/test/case/infix_firewall/Readme.adoc b/test/case/infix_firewall/Readme.adoc index fa39244ab..dd26ef29e 100644 --- a/test/case/infix_firewall/Readme.adoc +++ b/test/case/infix_firewall/Readme.adoc @@ -6,3 +6,5 @@ include::basic/test.py[] include::lan-wan/test.py[] + +include::wan-dmz-lan/test.py[] diff --git a/test/case/infix_firewall/infix_firewall.yaml b/test/case/infix_firewall/infix_firewall.yaml index 51b5e06f6..5438c9ccd 100644 --- a/test/case/infix_firewall/infix_firewall.yaml +++ b/test/case/infix_firewall/infix_firewall.yaml @@ -3,3 +3,5 @@ case: basic/test.py - name: lan-to-wan case: lan-wan/test.py +- name: wan-dmz-lan + case: wan-dmz-lan/test.py diff --git a/test/case/infix_firewall/wan-dmz-lan/Readme.adoc b/test/case/infix_firewall/wan-dmz-lan/Readme.adoc new file mode 120000 index 000000000..358ebc4cd --- /dev/null +++ b/test/case/infix_firewall/wan-dmz-lan/Readme.adoc @@ -0,0 +1 @@ +wan-dmz-lan.adoc \ No newline at end of file diff --git a/test/case/infix_firewall/wan-dmz-lan/test.py b/test/case/infix_firewall/wan-dmz-lan/test.py new file mode 100755 index 000000000..68de54364 --- /dev/null +++ b/test/case/infix_firewall/wan-dmz-lan/test.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +""" +WAN-DMZ-LAN Firewall with Port Forwarding + +Verifies a comprehensive multi-zone firewall setup with port forwarding (DNAT) +and masquerading (SNAT). + +Architecture: +- Target device = Gateway with WAN/DMZ/LAN zones and NAT +- Test host has four interfaces: WAN (Internet), DMZ (server zone), + LAN (internal), mgmt +- Host WAN interface acts as external Internet client +- Host DMZ interface acts as internal server (HTTP on port 80) +- Host LAN interface acts as internal LAN client + +The test verifies: +- WAN zone with action=drop for external interface +- DMZ zone with limited services (HTTP only) +- LAN zone with action=accept for internal network +- Port forwarding: WAN:8080 → DMZ:80 (DNAT) +- Policy loc-to-wan: DMZ+LAN → WAN with masquerading (SNAT) +- Policy lan-to-dmz: LAN → DMZ for SSH and HTTP access +- Proper zone isolation and access control +- End-to-end DNAT and SNAT functionality + +This validates complex firewall scenarios with both ingress NAT (DNAT) +and egress NAT (SNAT) plus comprehensive multi-zone policies. +""" +from time import sleep +import infamy +from infamy.util import until + +# Some helper classes return extra debug info which this switch unlocks +DEBUG = False + + +def debug(msg): + """Debug messages not relevant for regular test output""" + if DEBUG: + print(msg) + + +with infamy.Test() as test: + with test.step("Set up topology and attach to gateway"): + env = infamy.Env() + gateway = env.attach("gateway", "mgmt") + _, wan_if = env.ltop.xlate("gateway", "wan") + _, dmz_if = env.ltop.xlate("gateway", "dmz") + _, lan_if = env.ltop.xlate("gateway", "lan") + _, mgmt_if = env.ltop.xlate("gateway", "mgmt") + _, host_wan = env.ltop.xlate("host", "wan") + _, host_dmz = env.ltop.xlate("host", "dmz") + _, host_lan = env.ltop.xlate("host", "lan") + + WAN_NET = "203.0.113.0/24" # RFC 5737 test network + WAN_ROUTER_IP = "203.0.113.1" # Gateway WAN interface + WAN_CLIENT_IP = "203.0.113.100" # Host WAN interface + + DMZ_NET = "10.0.1.0/24" + DMZ_ROUTER_IP = "10.0.1.1" # Gateway DMZ interface + DMZ_SERVER_IP = "10.0.1.100" # Host DMZ interface + + LAN_NET = "192.168.1.0/24" + LAN_ROUTER_IP = "192.168.1.1" # Gateway LAN interface + LAN_CLIENT_IP = "192.168.1.100" # Host LAN interface + + with test.step("Configure gateway with multi-zone firewall and NAT"): + gateway.put_config_dict("ietf-interfaces", { + "interfaces": { + "interface": [ + { + "name": wan_if, + "enabled": True, + "ipv4": { + "forwarding": True, + "address": [{ + "ip": WAN_ROUTER_IP, + "prefix-length": 24 + }] + } + }, + { + "name": dmz_if, + "enabled": True, + "ipv4": { + "forwarding": True, + "address": [{ + "ip": DMZ_ROUTER_IP, + "prefix-length": 24 + }] + } + }, + { + "name": lan_if, + "enabled": True, + "ipv4": { + "forwarding": True, + "address": [{ + "ip": LAN_ROUTER_IP, + "prefix-length": 24 + }] + } + } + ] + } + }) + + gateway.put_config_dict("infix-firewall", { + "firewall": { + "default": "wan", + "logging": "all", + "zone": [ + { + "name": "wan", + "description": "External WAN interface - untrusted", + "action": "drop", + "interface": [wan_if], + "port-forward": [ + { + "lower": 8080, + "proto": "tcp", + "to": { + "addr": DMZ_SERVER_IP, + "port": 80 + } + } + ] + }, + { + "name": "dmz", + "description": "DMZ network - limited trust", + "action": "drop", + "interface": [dmz_if], + "service": ["http"] + }, + { + "name": "lan", + "description": "Internal LAN network - trusted", + "action": "accept", + "interface": [lan_if, mgmt_if], + "service": ["ssh", "dhcp", "dns"] + } + ], + "policy": [ + { + "name": "loc-to-wan", + "description": "Allow local networks to WAN with SNAT", + "ingress": ["lan", "dmz"], + "egress": ["wan"], + "action": "accept", + "masquerade": True + }, + { + "name": "lan-to-dmz", + "description": "Allow LAN access to DMZ services", + "ingress": ["lan"], + "egress": ["dmz"], + "action": "accept", + "service": ["ssh", "http"] + } + ] + } + }) + + debug("Waiting for gateway configuration to take effect...") + sleep(2) + + # Verify firewall configuration + config = gateway.get_config_dict("/infix-firewall:firewall") + fw = config["firewall"] + + assert fw["default"] == "wan" + assert len(fw["zone"]) == 3 + assert len(fw["policy"]) == 2 + + # Check zones + zones = {z["name"]: z for z in fw["zone"]} + + # Verify WAN zone with port forwarding + wan_zone = zones["wan"] + assert wan_zone["action"] == "drop" + assert wan_if in wan_zone["interface"] + assert len(wan_zone["port-forward"]) == 1 + pf = wan_zone["port-forward"][0] + assert pf["lower"] == 8080 + assert pf["to"]["addr"] == DMZ_SERVER_IP + assert pf["to"]["port"] == 80 + + # Verify DMZ zone + dmz_zone = zones["dmz"] + assert dmz_zone["action"] == "drop" + assert dmz_if in dmz_zone["interface"] + assert "http" in dmz_zone["service"] + + # Verify LAN zone + lan_zone = zones["lan"] + assert lan_zone["action"] == "accept" + assert lan_if in lan_zone["interface"] + + # Check policies + policies = {p["name"]: p for p in fw["policy"]} + + # Verify loc-to-wan policy + loc_wan_policy = policies["loc-to-wan"] + assert set(loc_wan_policy["ingress"]) == {"lan", "dmz"} + assert loc_wan_policy["egress"] == ["wan"] + assert loc_wan_policy["masquerade"] is True + + # Verify lan-to-dmz policy + lan_dmz_policy = policies["lan-to-dmz"] + assert lan_dmz_policy["ingress"] == ["lan"] + assert lan_dmz_policy["egress"] == ["dmz"] + assert "ssh" in lan_dmz_policy["service"] + assert "http" in lan_dmz_policy["service"] + + with infamy.IsolatedMacVlan(host_wan) as wan_client: + wan_client.addip(WAN_CLIENT_IP) + + with infamy.IsolatedMacVlan(host_dmz) as dmz_server: + dmz_server.addip(DMZ_SERVER_IP) + dmz_server.addroute("0.0.0.0", DMZ_ROUTER_IP, prefix_length="0") + + with infamy.IsolatedMacVlan(host_lan) as lan_client: + lan_client.addip(LAN_CLIENT_IP) + lan_client.addroute("0.0.0.0", LAN_ROUTER_IP, prefix_length="0") + + with test.step("Verify basic connectivity within zones"): + lan_client.must_reach(LAN_ROUTER_IP, timeout=3) + dmz_server.must_not_reach(DMZ_ROUTER_IP, timeout=3) + + with test.step("Verify WAN to DMZ port forwarding (DNAT)"): + firewall = infamy.Firewall(wan_client, dmz_server) + + # Test port forwarding: WAN:8080 → DMZ:80 + dnat_working, details = firewall.verify_dnat( + WAN_ROUTER_IP, forward_port=8080, target_port=80) + + if dnat_working: + debug(f" ✓ {details}") + else: + print(f" ⚠ {details}") + test.fail("DNAT port forwarding verification failed") + + with test.step("Verify LAN to DMZ connectivity"): + lan_client.must_reach(DMZ_SERVER_IP, timeout=3) + firewall = infamy.Firewall(lan_client, None) + svc = [ + (22, "tcp", "ssh"), + (80, "tcp", "http"), + ] + + ok, filtered = firewall.verify_allowed(DMZ_SERVER_IP, svc) + if ok: + debug(" ✓ LAN can access DMZ services (SSH, HTTP)") + else: + print(f" ⚠ Some DMZ services filtered from LAN: {', '.join(filtered)}") + + with test.step("Verify DMZ to LAN blocking"): + # DMZ should NOT be able to reach LAN (no policy exists) + dmz_server.must_not_reach(LAN_CLIENT_IP, timeout=3) + + with test.step("Verify WAN isolation"): + # WAN should NOT be able to reach LAN or DMZ directly + firewall = infamy.Firewall(wan_client, None) + + # Test LAN isolation from WAN + ok, open_ports, _ = firewall.verify_blocked(LAN_ROUTER_IP) + if ok: + debug(" ✓ LAN is isolated from WAN") + else: + print(f" ⚠ WAN can access LAN ports: {', '.join(open_ports)}") + test.fail("LAN not properly isolated from WAN") + + # Test DMZ isolation from WAN (except port forwarding) + ok, open_ports, _ = firewall.verify_blocked(DMZ_ROUTER_IP) + if ok: + debug(" ✓ DMZ is isolated from WAN") + else: + print(f" ⚠ WAN can access DMZ ports: {', '.join(open_ports)}") + + with test.step("Verify LAN to WAN connectivity with SNAT"): + # Test outbound connectivity + lan_client.must_reach(WAN_CLIENT_IP, timeout=3) + + # Verify SNAT masquerading + firewall = infamy.Firewall(lan_client, wan_client) + ok, info = firewall.verify_snat(WAN_CLIENT_IP, WAN_ROUTER_IP) + if ok: + debug(f" ✓ LAN to WAN SNAT: {info}") + else: + print(f" ⚠ LAN to WAN SNAT: {info}") + test.fail("LAN to WAN SNAT verification failed") + + with test.step("Verify DMZ to WAN connectivity with SNAT"): + # Test outbound connectivity + dmz_server.must_reach(WAN_CLIENT_IP, timeout=3) + + # Verify SNAT masquerading + firewall = infamy.Firewall(dmz_server, wan_client) + ok, info = firewall.verify_snat(WAN_CLIENT_IP, WAN_ROUTER_IP) + if ok: + debug(f" ✓ DMZ to WAN SNAT: {info}") + else: + print(f" ⚠ DMZ to WAN SNAT: {info}") + test.fail("DMZ to WAN SNAT verification failed") + + with test.step("Verify comprehensive zone policies"): + # Test that each zone has appropriate service accessibility + firewall_lan = infamy.Firewall(lan_client, None) + firewall_dmz = infamy.Firewall(dmz_server, None) + firewall_wan = infamy.Firewall(wan_client, None) + + # LAN zone should allow configured services + svc = [ + (22, "tcp", "ssh"), + (53, "udp", "dns"), + (67, "udp", "dhcp") + ] + ok, filtered = firewall_lan.verify_allowed(LAN_ROUTER_IP, svc) + if not ok: + print(f" ⚠ LAN services not properly accessible: {', '.join(filtered)}") + + # DMZ zone should allow only HTTP + svc = [(80, "tcp", "http")] + ok, filtered = firewall_dmz.verify_allowed(DMZ_ROUTER_IP, svc) + if not ok: + print(f" ⚠ DMZ HTTP service not accessible: {', '.join(filtered)}") + + # WAN zone should block all standard services + ok, open_ports, _ = firewall_wan.verify_blocked(WAN_ROUTER_IP) + if not ok: + print(f" ⚠ WAN has unexpected open ports: {', '.join(open_ports)}") + + test.succeed() diff --git a/test/case/infix_firewall/wan-dmz-lan/topology.dot b/test/case/infix_firewall/wan-dmz-lan/topology.dot new file mode 100644 index 000000000..c087f7b48 --- /dev/null +++ b/test/case/infix_firewall/wan-dmz-lan/topology.dot @@ -0,0 +1,25 @@ +graph "1x4" { + layout = "neato"; + overlap = false; + esep = "+80"; + + node [shape=record, fontname="DejaVu Sans Mono, Book"]; + edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"]; + + host [ + label="host | { mgmt | wan | dmz | lan }", + pos="1,1!", + requires="controller" + ]; + + gateway [ + label="{ mgmt | wan | dmz | lan } | gateway", + pos="3,1!", + requires="infix", + ]; + + host:mgmt -- gateway:mgmt [requires="mgmt", color="lightgray"] + host:wan -- gateway:wan [color=red, fontcolor=red, taillabel="203.0.113.0/24"] + host:dmz -- gateway:dmz [color=orange, fontcolor=orange, taillabel="10.0.1.0/24"] + host:lan -- gateway:lan [color=black, fontcolor=black, taillabel="192.168.1.0/24"] +} diff --git a/test/case/infix_firewall/wan-dmz-lan/topology.svg b/test/case/infix_firewall/wan-dmz-lan/topology.svg new file mode 100644 index 000000000..9e27552f6 --- /dev/null +++ b/test/case/infix_firewall/wan-dmz-lan/topology.svg @@ -0,0 +1,63 @@ + + + + + + +1x4 + + + +host + +host + +mgmt + +wan + +dmz + +lan + + + +gateway + +mgmt + +wan + +dmz + +lan + +gateway + + + +host:mgmt--gateway:mgmt + + + + +host:wan--gateway:wan + +203.0.113.0/24 + + + +host:dmz--gateway:dmz + +10.0.1.0/24 + + + +host:lan--gateway:lan + +192.168.1.0/24 + + + diff --git a/test/case/infix_firewall/wan-dmz-lan/wan-dmz-lan.adoc b/test/case/infix_firewall/wan-dmz-lan/wan-dmz-lan.adoc new file mode 100644 index 000000000..35aab19fa --- /dev/null +++ b/test/case/infix_firewall/wan-dmz-lan/wan-dmz-lan.adoc @@ -0,0 +1,53 @@ +=== WAN-DMZ-LAN Firewall with Port Forwarding +==== Description +Verifies a comprehensive multi-zone firewall setup with port forwarding (DNAT) +and masquerading (SNAT). + +Architecture: +- Target device = Gateway with WAN/DMZ/LAN zones and NAT +- Test host has four interfaces: WAN (Internet), DMZ (server zone), + LAN (internal), mgmt +- Host WAN interface acts as external Internet client +- Host DMZ interface acts as internal server (HTTP on port 80) +- Host LAN interface acts as internal LAN client + +The test verifies: +- WAN zone with action=drop for external interface +- DMZ zone with limited services (HTTP only) +- LAN zone with action=accept for internal network +- Port forwarding: WAN:8080 → DMZ:80 (DNAT) +- Policy loc-to-wan: DMZ+LAN → WAN with masquerading (SNAT) +- Policy lan-to-dmz: LAN → DMZ for SSH and HTTP access +- Proper zone isolation and access control +- End-to-end DNAT and SNAT functionality + +This validates complex firewall scenarios with both ingress NAT (DNAT) +and egress NAT (SNAT) plus comprehensive multi-zone policies. + +==== Topology +ifdef::topdoc[] +image::{topdoc}../../test/case/infix_firewall/wan-dmz-lan/topology.svg[WAN-DMZ-LAN Firewall with Port Forwarding topology] +endif::topdoc[] +ifndef::topdoc[] +ifdef::testgroup[] +image::wan-dmz-lan/topology.svg[WAN-DMZ-LAN Firewall with Port Forwarding topology] +endif::testgroup[] +ifndef::testgroup[] +image::topology.svg[WAN-DMZ-LAN Firewall with Port Forwarding topology] +endif::testgroup[] +endif::topdoc[] +==== Test sequence +. Set up topology and attach to gateway +. Configure gateway with multi-zone firewall and NAT +. Verify basic connectivity within zones +. Verify WAN to DMZ port forwarding (DNAT) +. Verify LAN to DMZ connectivity +. Verify DMZ to LAN blocking +. Verify WAN isolation +. Verify LAN to WAN connectivity with SNAT +. Verify DMZ to WAN connectivity with SNAT +. Verify comprehensive zone policies + + +<<< + diff --git a/test/infamy/firewall.py b/test/infamy/firewall.py index efb0944ba..04495de6b 100644 --- a/test/infamy/firewall.py +++ b/test/infamy/firewall.py @@ -8,6 +8,7 @@ - Zone policy checks using targeted port scans - Positive and negative policy validation (allowed vs. blocked ports) """ +import subprocess import time from typing import Tuple, List from .sniffer import Sniffer @@ -160,3 +161,44 @@ def verify_allowed(self, dest_ip: str, ports: List[Tuple[int, str, str]] = None, filtered_ports.append(f"{name}({port})") return len(filtered_ports) == 0, filtered_ports + + def verify_dnat(self, gateway_ip: str, forward_port: int, target_port: int, + timeout: int = 5) -> Tuple[bool, str]: + """ + Verify DNAT (port forwarding) by testing end-to-end connectivity + + Args: + gateway_ip: Gateway IP where port forwarding is configured + forward_port: External port being forwarded (e.g., 8080) + target_port: Internal target port (e.g., 80) + timeout: Connection timeout + Returns: + Tuple of (dnat_working: bool, details: str) + """ + try: + # Use netcat to simulate a simple service on target port + cmd = f"nc -l -p {target_port} -e /bin/echo 'DNAT-TEST-OK'" + pid = self.dstns.popen(cmd.split(), stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + time.sleep(1) # Give server time to start + + # Test connection from source to gateway:forward_port + cmd = f"nc -w {timeout} {gateway_ip} {forward_port}" + result = self.srcns.runsh(cmd) + + try: + pid.terminate() + pid.wait(timeout=1) + except: + pid.kill() + + # Check if we got the expected response + if "DNAT-TEST-OK" in result.stdout: + return True, f"DNAT working: {gateway_ip}:{forward_port} → target:{target_port}" + if result.returncode == 0: + return True, f"DNAT working: connection successful to {gateway_ip}:{forward_port}" + + return False, f"DNAT failed: no response from {gateway_ip}:{forward_port}" + + except Exception as e: + return False, f"DNAT verification failed with error: {e}" From ee15b8b239077d5fe9714beb2bb9a7f5a5285715 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 23 Aug 2025 07:09:08 +0200 Subject: [PATCH 43/55] Update fw TODOs Signed-off-by: Joachim Wiberg --- doc/TODO.org | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/doc/TODO.org b/doc/TODO.org index b4ccdb2a7..a0e93168f 100644 --- a/doc/TODO.org +++ b/doc/TODO.org @@ -23,10 +23,11 @@ - [ ] Podman published ports, - [ ] Software fastpath - [ ] Allow overriding/editing immutable policies and zones -- [ ] Add tests: basic (end device), wan-lan, wan-lan-dmz, hammer (stress) +- [X] Add tests: basic (end device), wan-lan, wan-lan-dmz, +hammer (stress)+ - [ ] Add documentation - See - Add some tool tips: nc, nmap, ping, and socat to stress the firewall +- [ ] Fix inference so we can remove defaults from factory-config! * TODO doc: User Guide From bf7285e4c1625f55efd30a532023114f0c99ee21 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 23 Aug 2025 07:46:23 +0200 Subject: [PATCH 44/55] doc: draft firewall documentation Signed-off-by: Joachim Wiberg --- doc/firewall.md | 226 ++++++++++++++++++++++++++++++++++++++++++ doc/img/firewall.svg | 1 + doc/img/fw-logs.png | Bin 0 -> 50566 bytes doc/img/fw-matrix.png | Bin 0 -> 26587 bytes doc/img/fw-zones.svg | 4 + mkdocs.yml | 3 +- 6 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 doc/firewall.md create mode 100644 doc/img/firewall.svg create mode 100644 doc/img/fw-logs.png create mode 100644 doc/img/fw-matrix.png create mode 100644 doc/img/fw-zones.svg diff --git a/doc/firewall.md b/doc/firewall.md new file mode 100644 index 000000000..ac0f0f59a --- /dev/null +++ b/doc/firewall.md @@ -0,0 +1,226 @@ +# Firewall Documentation + +## Introduction + +The Infix firewall aims to simplify network security. Instead of complex +per-interface rules, you work with *zones*. A zone defines a level of trust +between all interfaces assigned to it and security policies regulate traffic +flow between zones. + +![Firewall](img/firewall.svg){ align=left width="180" } + +This approach is intuitive and maintainable — you think in terms of trust +relationships ("internal networks can access the Internet", "Internet cannot +access my internal network") rather than individual interface rules. When you +add new interfaces to existing zones, they automatically inherit the +established security policies. + +The firewall controls three distinct traffic flows: traffic destined for the +host itself, traffic between interfaces within the same zone, and traffic +between different zones. + +> [!TIP] Impatient and ready to get going? +> [Fast forward to the Examples: End Device, Home/Office Router, Enterprise Gateway](#examples) + +## Zones + +Zones are logical groupings of network interfaces that share the same trust +level. Each zone has a default *action* that determines what happens to +traffic destined for the host itself (INPUT chain). A LAN zone may have this +set to *accept*, while a DMZ zone may be set to *reject* by default and only +allow a subset of *services*, e.g., DHCP, DNS, and SSH, on input. + +### Intra-Zone Traffic + +For a LAN zone, the trust level can be set to allow IP routing between all +interfaces and networks. This is often referred to as *intra-zone* traffic +and is controlled by the zone's `forwarding` setting, it works independently +of the zone action. When enabled, devices on different interfaces within the +same zone can communicate directly with each other. + +> [!NOTE] Remember IP forwarding! +> Allowing forwarding between interfaces in the zone is not enough, this only +> prevents the firewall from actively blocking the traffic flows, you also +> need to enable [IP forwarding](network.md#ipv4-forwarding). + +### Port Forwarding + +Each zone can have port forwarding rules (DNAT) that redirect incoming traffic +from one port to a different internal address and port. This is effectively a +security policy that allows external access to internal services. + +The *Firewall Matrix* shows a ⚠ conditional warning flag, coloring the zone +yellow, when policy exceptions like port forwarding are active. + +![Firewall Matrix](img/fw-matrix.png) + +### Default Zone Concept + +Infix requires you to specify a default zone. Any interface not explicitly +assigned to a zone automatically belongs to the default zone. This ensures +that no interface is left without firewall protection. + +Choose your default zone carefully — it should be the most restrictive zone +appropriate for unmanaged interfaces. For routers, this is typically the +`wan` zone. + +## Policies + +![Zone based firewall](img/fw-zones.svg){ align=right width="420" } + +Policy rules control traffic **between** zones. By default all inter-zone +traffic is rejected. Meaning you must explicitly allow the traffic flows +you intend. + +IP masquerading (SNAT) is a policy setting that applies to traffic egressing +a target zone. (Essential for Internet access from private networks.) + +A policy, like zones, have a default action. If it is *not* set to `accept` +you must specify which services are allowed. + +See the [examples below](#enterprise-gateway) for how to set up a policy. The +built-in help system can also be useful: + +``` +admin@example:/config/firewall/policy/lan-to-dmz/> help masquerade +NAME + masquerade + +DESCRIPTION + Enable masquerading (SNAT) for traffic matching this policy. + +admin@example:/config/firewall/policy/lan-to-dmz/> +``` + +## Services + +Several pre-defined services exist, that cover most use-cases, but you can +also define custom services for applications not covered by the built-in ones. + +### Built-in services + +Infix provides predefined services for common protocols: + +- **`ssh`**: Secure Shell (port 22/tcp) +- **`http`**: Web traffic (port 80/tcp) +- **`https`**: Secure web traffic (port 443/tcp) +- **`dns`**: Domain Name System (port 53/tcp and 53/udp) +- **`dhcp`**: DHCP server (port 67/udp) +- **`dhcpv6-client`**: DHCPv6 client traffic +- **`netconf`**: Network Configuration Protocol (port 830/tcp) +- **`restconf`**: REST-based Network Configuration Protocol (port 443/tcp) + +... and more, see `infix-firewall-services.yang` for details + +## Examples + +### End Device Protection + +For devices on untrusted networks like public Wi-Fi or other open Internet +connections. Provides maximum protection while allowing essential +connectivity. + +``` +admin@example:/> configure +admin@example:/config/> edit firewall +admin@example:/config/firewall/> set default public +admin@example:/config/firewall/> edit zone public +admin@example:/config/firewall/zone/public/> set description "Public untrusted network - end device protection" +admin@example:/config/firewall/zone/public/> set action drop +admin@example:/config/firewall/zone/public/> set interface eth0 +admin@example:/config/firewall/zone/public/> set service ssh +admin@example:/config/firewall/zone/public/> set service dhcpv6-client +admin@example:/config/firewall/zone/public/> leave +``` + +### Home/Office Router + +For typical routers that need to protect internal devices while providing +internet access. The LAN zone trusts internal devices, while the WAN zone +blocks external threats. + +``` +admin@example:/> configure +admin@example:/config/> edit firewall +admin@example:/config/firewall/> set default wan +admin@example:/config/firewall/> edit zone lan +admin@example:/config/firewall/zone/lan/> set description "Internal LAN network - trusted" +admin@example:/config/firewall/zone/lan/> set action accept +admin@example:/config/firewall/zone/lan/> set interface eth1 +admin@example:/config/firewall/zone/lan/> set service ssh +admin@example:/config/firewall/zone/lan/> set service dhcp +admin@example:/config/firewall/zone/lan/> set service dns +admin@example:/config/firewall/zone/lan/> end +admin@example:/config/firewall/> edit zone wan +admin@example:/config/firewall/zone/wan/> set description "External WAN interface - untrusted" +admin@example:/config/firewall/zone/wan/> set action drop +admin@example:/config/firewall/zone/wan/> set interface eth0 +admin@example:/config/firewall/zone/wan/> end +admin@example:/config/firewall/> edit policy loc-to-wan +admin@example:/config/firewall/policy/loc-to-wan/> set description "Allow local LAN/DMZ traffic to WAN with SNAT" +admin@example:/config/firewall/policy/loc-to-wan/> set ingress lan +admin@example:/config/firewall/policy/loc-to-wan/> set egress wan +admin@example:/config/firewall/policy/loc-to-wan/> set action accept +admin@example:/config/firewall/policy/loc-to-wan/> set masquerade +admin@example:/config/firewall/policy/loc-to-wan/> leave +``` + +> [!NOTE] +> Policy rules apply in a stateful, unidirectional manner. Meaning, you only +> consider one direction of the traffic. The return traffic (established, +> related) is implicitly allowed. + +### Enterprise Gateway + +For businesses that need to host public services while protecting internal +resources. We can build upon the Home/Office Router example above and add +a DMZ zone with additional policies for controlled access. + +``` +admin@example:/> configure +admin@example:/config/> edit firewall zone dmz +admin@example:/config/firewall/zone/dmz/> set description "Semi-trusted public services" +admin@example:/config/firewall/zone/dmz/> set action drop +admin@example:/config/firewall/zone/dmz/> set interface eth1 +admin@example:/config/firewall/zone/dmz/> set service ssh +admin@example:/config/firewall/zone/dmz/> end +admin@example:/config/firewall/> edit policy lan-to-wan +admin@example:/config/firewall/policy/lan-to-wan/> set ingress dmz +admin@example:/config/firewall/policy/lan-to-wan/> end +admin@example:/config/firewall/> edit policy lan-to-dmz +admin@example:/config/firewall/policy/lan-to-dmz/> set description "Allow LAN to manage DMZ services" +admin@example:/config/firewall/policy/lan-to-dmz/> set ingress lan +admin@example:/config/firewall/policy/lan-to-dmz/> set egress dmz +admin@example:/config/firewall/policy/lan-to-dmz/> set action accept +admin@example:/config/firewall/policy/lan-to-dmz/> end +admin@example:/config/firewall/> edit zone wan port-forward 8080 tcp +admin@example:/config/firewall/zone/wan/port-forward/8080/tcp/> set to addr 192.168.2.10 +admin@example:/config/firewall/zone/wan/port-forward/8080/tcp/> set to port 80 +admin@example:/config/firewall/zone/wan/port-forward/8080/tcp/> leave +``` + +This adds a DMZ zone for public services, updates the internet access policy +to include DMZ traffic, allows LAN management of DMZ services, and forwards +external web traffic to the DMZ server. + +## Logging and Monitoring + +Different log levels are available to monitor and debug firewall behavior. +Configure logging using the CLI: + +``` +admin@example:/> configure +admin@example:/config/> edit firewall +admin@example:/config/firewall/> set logging all +admin@example:/config/firewall/> leave +``` + +Firewall logs help you understand traffic patterns and security events. The +CLI admin-exec command `show firewall` shows the last 10 log messages in the +overview: + +![Firewall logs](img/fw-logs.png) + +Use the command `show log firewall.log` to display the full logfile (remember, +the syslog daemon rotates and zips too big log files). You can also use the +`follow firewall.log` command to continuously monitor firewall log messages. diff --git a/doc/img/firewall.svg b/doc/img/firewall.svg new file mode 100644 index 000000000..bad820f1c --- /dev/null +++ b/doc/img/firewall.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/doc/img/fw-logs.png b/doc/img/fw-logs.png new file mode 100644 index 0000000000000000000000000000000000000000..34e5f44711729dd474171c7f3cc43647ad7ceb4d GIT binary patch literal 50566 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

4bH{tAkQ9e(pn~2NsTn-p* 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�Zw; z9JAmtNd41RWKZ_!w}9z!9UjSs83Ijt(TPmA29CJWs>9dz9EV_mwmQVi^jn;6Kd^keRiPwe%QPL$G!|8@`a#$bDg~M;~ zLclbb5Y6wt?h)fkHC(hJ>G!6;6DdyxGaw-^*DpH?=~ z3NZ0>(l!!b{oA0t0=bjvxiCupS*-#9ytq-2me5+A60k+j%4RIoN{b>5Cq0))c*mPq z`R0N`Jy6b2H!emJf4wvAeHGsMRmy#=t2VLKzs<)E|D2Ccthy_;y|`U5w5aUX8LiOu zgijzLoLsOLG#l=y_wZ8=lkl^@sm4lyYOTrHhI;b1;b8Y^QDe;9@B!cD< zKTN$PXO9|lN^!@oTs|We@LyqVC4ZFwpj@J;_jVxa$G^kiZ|P=pZ1X4cU{Y4I*p!rJ zfp2e-Qc)ae@WY?BdV&R^4Pd{EudO&f3?(Y92d6!;&_GLcaKYXfO$oK zEB&Qu{U`WF+8QN8AAXHB$YB6p04Wfma*|9=HL&SpcXv==E}9xGK>rht~6TNLs0y8q5|>dx{+|5;R!$2C0H)bRe6D4q|9T~MKp zaQP&>qsu`n>2Cx#X7hDNk6re+YVj1ThYRKn;K~f{;^2d#;Z?-dJZB2}3tj2o=0`_g zGIBG9vyo;=)a=j>{yV;bF_rx=MwdFWAxlB@4s6`xiwCHU(@|UBta~ufmItD^%rdZ< zF#C}m(0H-ha6$m`)8=niK`lvw%#8<|BGz-ieNxKI8QaQDw4E3QCPEO|MHCm`%rLan zCw@&XX7E))cJoLU148Bv{SEO01_K-f6Qc1HGaR6s0VNO@ONLg%&f79=xUuDtu=BQ>zM`%yuRr9gng>qo@~;InvH{QXi-lI361mlW0iH!oa5nbxFkI2(Gc0kAIx MnRn8)lE%UR4NAD4KmY&$ literal 0 HcmV?d00001 diff --git a/doc/img/fw-matrix.png b/doc/img/fw-matrix.png new file mode 100644 index 0000000000000000000000000000000000000000..c797c7b49597faee19b4a7a359781096a3b8fe95 GIT binary patch literal 26587 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

;KJJ55&XUW7-}kq`?1S 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 + + +
port fwd
DMZ
LAN
WAN
loc-to-wan
lan-to-dmz
\ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 69a35d5c0..0b8ea73f5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -23,12 +23,13 @@ nav: - Keybindings: cli/keybindings.md - Network Calculator: cli/netcalc.md - Network Monitoring: cli/tcpdump.md - - Quickstart Guide: cli/quick.md + - Quickstart Guide: cli/quick.md - Text Editor: cli/text-editor.md - Upgrading: cli/upgrade.md - Docker Containers: container.md - Networking: - Network Configuration: networking.md + - Firewall Configuration: firewall.md - Quality of Service: qos.md - RMON Counters: eth-counters.md - Tunneling (L2/L3): tunnels.md From 1d1a35d8298c3962e465260de927189b48513215 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 24 Aug 2025 11:32:07 +0200 Subject: [PATCH 45/55] confd: minor, reorder leafs a bit and udpate descriptions Signed-off-by: Joachim Wiberg --- src/confd/src/infix-firewall.c | 24 +------ src/confd/yang/confd/infix-firewall.yang | 90 +++++++----------------- 2 files changed, 27 insertions(+), 87 deletions(-) diff --git a/src/confd/src/infix-firewall.c b/src/confd/src/infix-firewall.c index 789fec20a..27d118354 100644 --- a/src/confd/src/infix-firewall.c +++ b/src/confd/src/infix-firewall.c @@ -252,19 +252,8 @@ static int generate_zone(struct lyd_node *cfg, const char *name, char **ifaces) } } -#if 0 /* ADVANCED FEATURE: icmp-blocks removed from YANG model */ - LYX_LIST_FOR_EACH(lyd_child(cfg), node, "icmp-blocks") { - const char *icmp_type = lydx_get_cattr(node, "icmp-type"); - fprintf(fp, " \n", icmp_type); - } -#endif - if (lydx_is_enabled(cfg, "forwarding")) fprintf(fp, " \n"); -#if 0 /* REMOVED: Zone-level masquerade - handled by policy rules instead */ - if (lydx_is_enabled(cfg, "masquerade")) - fprintf(fp, " \n"); -#endif fprintf(fp, "\n"); @@ -455,7 +444,7 @@ static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module goto err_release_data; if (!global) { - /* Firewall is disabled - clean up all firewalld configuration files */ + /* Firewall is dactivated - clean up all firewalld configuration files */ cleanup_files(); goto done; } @@ -548,17 +537,6 @@ static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module return err; } -/* - * Set up default zones with sane defaults: - * - * internal: This is the default zone, which trusts all ingressing traffic. - * It is used for internal trusted networks and allows forwarding - * between all interfaces and networks in the zone. - * - * external: Untrusted zone, for WAN interfaces, only ssh and dhcpv6 client - * traffic is allowed to ingress. - * - */ static int cand(sr_session_ctx_t *session, uint32_t sub_id, const char *module, const char *path, sr_event_t event, unsigned request_id, void *priv) { diff --git a/src/confd/yang/confd/infix-firewall.yang b/src/confd/yang/confd/infix-firewall.yang index 443565f9f..57fbabe32 100644 --- a/src/confd/yang/confd/infix-firewall.yang +++ b/src/confd/yang/confd/infix-firewall.yang @@ -15,7 +15,7 @@ module infix-firewall { import infix-firewall-services { prefix ifw-svc; - reference "infix-firewall-services.yang"; + reference "internal"; } organization "KernelKit"; @@ -47,7 +47,7 @@ module infix-firewall { description "Accept all connections by default."; } enum reject { - description "Reject all connections by default."; + description "Reject all connections, except ICMP, by default."; } enum drop { description "Drop all connections by default."; @@ -69,7 +69,6 @@ module infix-firewall { typedef policy-action { type enumeration { - /* ADVANCED FEATURE: Commented out - requires policy ordering/priority system */ enum continue { description "Non-terminal policy. Matching traffic is accepted or allowed to proceed, and other policies continue to be evaluated."; } @@ -169,7 +168,7 @@ module infix-firewall { container firewall { description "Zone-based firewall configuration."; - presence "Enable the firewall."; + presence "Activte firewall."; /* leaf enabled { description "Enable or disable the firewall. @@ -181,12 +180,6 @@ module infix-firewall { type boolean; } */ - leaf lockdown { - description "Current state of emergency lockdown mode."; - config false; - type boolean; - } - leaf default { description "Default zone for interfaces. @@ -228,6 +221,14 @@ module infix-firewall { type ident; } + leaf action { + description "Default for ingressing traffic not matching an explicit rule. + + Note, see also the 'forwarding' setting for this zone."; + type zone-action; + default reject; + } + leaf immutable { description "Indicates if this zone is read-only/system-defined and cannot be modified."; config false; @@ -239,14 +240,6 @@ module infix-firewall { type string; } - leaf action { - description "Default for ingressing traffic not matching an explicit rule. - - Note, see also the 'forwarding' setting for this zone."; - type zone-action; - default reject; - } - leaf-list interface { description "List of interfaces assigned to this zone."; type if:interface-ref; @@ -262,9 +255,9 @@ module infix-firewall { } leaf forwarding { - description "Allow forwarding between interfaces/networks in the same zone. + description "Allow forwarding between interfaces/networks in the same zone (intra-zone). - Note, this setting applies regardless of (before) the zone action!"; + Note, this is a policy rule that applies before the zone action!"; type boolean; } @@ -314,22 +307,10 @@ module infix-firewall { type ifw-svc:well-known-service; } } - - /* ADVANCED FEATURE: Commented out for simplicity - users rarely need granular ICMP control - list icmp-blocks { - description "ICMP and ICMPv6 types to block."; - key "icmp-type"; - - leaf icmp-type { - description "ICMP type."; - type icmp-type; - } - } - */ } list policy { - description "Rules for filtering traffic forwarded between zones."; + description "Rules for filtering traffic forwarded between zones (inter-zone)."; key "name"; leaf name { @@ -337,6 +318,12 @@ module infix-firewall { type ident; } + leaf action { + description "Action for non-matching traffic. All policies are terminal (accept/reject/drop)."; + type policy-action; + default "reject"; + } + leaf immutable { description "Indicates if this policy is read-only/system-defined and cannot be modified."; config false; @@ -363,12 +350,6 @@ module infix-firewall { type boolean; } - leaf action { - description "Action for non-matching traffic. All policies are terminal (accept/reject/drop)."; - type policy-action; - default "reject"; - } - leaf-list service { description "Allowed services, all other traffic follows the policy default action."; type union { @@ -433,6 +414,12 @@ module infix-firewall { */ } + leaf lockdown { + description "Current state of emergency lockdown mode."; + config false; + type boolean; + } + action lockdown-mode { description "Emergency lockdown mode blocks all network traffic. @@ -463,31 +450,6 @@ module infix-firewall { mandatory true; } } - /* ADVANCED FEATURE: Skipped for now, please see node lockdown-state, below. - output { - leaf status { - description "Current lockdown mode status after operation"; - type enumeration { - enum active { - description "Lockdown mode is currently active"; - } - enum inactive { - description "Lockdown mode is currently inactive"; - } - enum error { - description "Operation failed"; - } - } - } - - leaf message { - description "Additional status or error information"; - config false; - type string; - } - } - */ - } } } From c7ad821e6ce1b8fe082a5ae8ac651ccede62168b Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 24 Aug 2025 14:22:44 +0200 Subject: [PATCH 46/55] confd: initial support for firewalld rich rules Signed-off-by: Joachim Wiberg --- src/confd/src/infix-firewall.c | 31 ++++ src/confd/yang/confd/infix-firewall.yang | 202 ++++++++++++++++++---- src/statd/python/cli_pretty/cli_pretty.py | 104 ++++++++++- src/statd/python/yanger/infix_firewall.py | 48 +++++ 4 files changed, 349 insertions(+), 36 deletions(-) diff --git a/src/confd/src/infix-firewall.c b/src/confd/src/infix-firewall.c index 27d118354..957204e0f 100644 --- a/src/confd/src/infix-firewall.c +++ b/src/confd/src/infix-firewall.c @@ -333,6 +333,37 @@ static int generate_policy(struct lyd_node *cfg, const char *name) LYX_LIST_FOR_EACH(lyd_child(cfg), node, "service") fprintf(fp, " \n", lyd_get_value(node)); + /* Handle custom filters */ + node = lydx_get_descendant(cfg, "policy", "custom", NULL); + if (node) { + struct lyd_node *filter; + + LYX_LIST_FOR_EACH(lyd_child(node), filter, "filter") { + const char *family = lydx_get_cattr(filter, "family"); + struct lyd_node *icmp; + + if (strcmp(family, "both")) + fprintf(fp, " \n", family); + else + fprintf(fp, " \n"); + + action = lydx_get_cattr(filter, "action"); + icmp = lydx_get_descendant(filter, "filter", "icmp", NULL); + if (icmp) { + const char *type = lydx_get_cattr(icmp, "type"); + + if (strcmp(action, "reject") == 0) { + fprintf(fp, " \n", type); + } else { + fprintf(fp, " \n", type); + fprintf(fp, " <%s/>\n", action); + } + } + + fprintf(fp, " \n"); + } + } + if (masquerade) fprintf(fp, " \n"); diff --git a/src/confd/yang/confd/infix-firewall.yang b/src/confd/yang/confd/infix-firewall.yang index 57fbabe32..efcf99d93 100644 --- a/src/confd/yang/confd/infix-firewall.yang +++ b/src/confd/yang/confd/infix-firewall.yang @@ -56,10 +56,10 @@ module infix-firewall { } typedef zone-ref { - description "Reference to a named zone or symbolic value: 'host' or 'any'."; + description "Reference to a named zone or symbolic value: 'HOST' or 'ANY'."; type union { type string { - pattern 'host|any'; + pattern 'HOST|ANY'; } type leafref { path "../../zone/name"; @@ -105,62 +105,147 @@ module infix-firewall { } /* firewall-cmd --get-icmptypes */ - /* ADVANCED FEATURE: Commented out for simplicity - users rarely need granular ICMP control typedef icmp-type { - description "Available ICMP/ICMPv6 types for icmp-blocks."; + description "Available ICMP/ICMPv6 types."; type enumeration { - enum echo-reply { - description "Echo reply."; + enum address-unreachable { + description "Error sent when a packet cannot be delivered to its IPv6 destination address."; + } + enum bad-header { + description "IPv6 error indicating there is a problem with the packet header structure or format."; + } + enum beyond-scope { + description "IPv6 error sent when transmitting a packet would cross a zone boundary of the source address scope."; + } + enum communication-prohibited { + description "Error indicating that communication with the destination has been administratively blocked."; } enum destination-unreachable { - description "Destination unreachable."; + description "General error sent by hosts or gateways when a destination cannot be reached."; } - enum source-quench { - description "Source quench."; + enum echo-reply { + description "Response message sent back to acknowledge receipt of an echo request (ping response/pong)."; + } + enum echo-request { + description "Test message used to check if a host is reachable, commonly sent by the ping utility."; + } + enum failed-policy { + description "IPv6 error indicating the source address failed to meet ingress or egress policy requirements."; + } + enum fragmentation-needed { + description "IPv4 error sent when a packet needs fragmentation but the 'Don't Fragment' flag is set."; + } + enum host-precedence-violation { + description "IPv4 error sent when communication is administratively prohibited due to precedence rules."; + } + enum host-prohibited { + description "IPv4 error indicating that access from a specific host has been administratively blocked."; + } + enum host-redirect { + description "IPv4 message instructing to redirect packets to a different route for the specific host."; + } + enum host-unknown { + description "IPv4 error sent when the destination host cannot be identified or located."; + } + enum host-unreachable { + description "IPv4 error sent when the destination host exists but cannot be reached."; + } + enum ip-header-bad { + description "IPv4 error indicating malformed or corrupted IP header information."; + } + enum neighbour-advertisement { + description "IPv6 message sent in response to neighbor solicitation to propagate new network information."; + } + enum neighbour-solicitation { + description "IPv6 message used to discover link-layer addresses of neighbors and verify reachability."; + } + enum network-prohibited { + description "IPv4 error sent when access to an entire network has been administratively blocked."; + } + enum network-redirect { + description "IPv4 message instructing to redirect packets to a different route for the entire network."; + } + enum network-unknown { + description "IPv4 error sent when the destination network cannot be identified or located."; + } + enum network-unreachable { + description "IPv4 error sent when the destination network exists but cannot be reached."; + } + enum no-route { + description "IPv6 error sent when there is no routing table entry available for the destination."; + } + enum packet-too-big { + description "IPv6 error sent by routers when they cannot forward a packet because it exceeds the MTU."; + } + enum parameter-problem { + description "Error sent when IP header contains bad parameters or missing required options."; + } + enum port-unreachable { + description "Error sent when the destination port on a reachable host is not available or not listening."; + } + enum precedence-cutoff { + description "IPv4 error sent when the packet's precedence level is lower than the required minimum."; + } + enum protocol-unreachable { + description "IPv4 error sent when the specified protocol is not supported at the destination."; } enum redirect { - description "Redirect."; + description "General message instructing a host to use a different route for future packets."; } - enum echo-request { - description "Echo request."; + enum reject-route { + description "IPv6 error sent when the routing table explicitly rejects the route to the destination."; + } + enum required-option-missing { + description "IPv4 error sent when a mandatory IP option is not present in the packet header."; } enum router-advertisement { - description "Router advertisement."; + description "Message sent by routers to periodically announce their presence and network configuration."; } enum router-solicitation { - description "Router solicitation."; + description "Message sent by hosts to request router advertisements and discover available routers."; + } + enum source-quench { + description "IPv4 flow control message telling a host to reduce its packet transmission rate."; + } + enum source-route-failed { + description "IPv4 error sent when source routing specified in the packet cannot be completed."; } enum time-exceeded { - description "Time exceeded."; + description "Error sent when a packet's time-to-live expires during transit or reassembly."; } - enum parameter-problem { - description "Parameter problem."; + enum timestamp-reply { + description "IPv4 response message containing timestamp information for network time synchronization."; } enum timestamp-request { - description "Timestamp request."; + description "IPv4 message requesting timestamp information from the destination for time synchronization."; } - enum timestamp-reply { - description "Timestamp reply."; + enum tos-host-redirect { + description "IPv4 message instructing to redirect packets based on both the type of service and specific host."; } - enum address-mask-request { - description "Address mask request."; + enum tos-host-unreachable { + description "IPv4 error sent when a host is unreachable for the specific type of service requested."; } - enum address-mask-reply { - description "Address mask reply."; + enum tos-network-redirect { + description "IPv4 message instructing to redirect packets based on both the type of service and network."; } - enum packet-too-big { - description "Packet too big."; + enum tos-network-unreachable { + description "IPv4 error sent when a network is unreachable for the specific type of service requested."; + } + enum ttl-zero-during-reassembly { + description "Error sent when a host fails to completely reassemble fragmented packets within the time limit."; + } + enum ttl-zero-during-transit { + description "Error sent when a packet's time-to-live counter reaches zero while being forwarded."; } - enum neighbor-solicitation { - description "Neighbor solicitation."; + enum unknown-header-type { + description "IPv6 error sent when an unrecognized Next Header type is encountered in the packet."; } - enum neighbor-advertisement { - description "Neighbor advertisement."; + enum unknown-option { + description "IPv6 error sent when an unrecognized or unsupported IPv6 option is encountered."; } } } - */ /* * Main container and configuration @@ -311,6 +396,7 @@ module infix-firewall { list policy { description "Rules for filtering traffic forwarded between zones (inter-zone)."; + ordered-by user; key "name"; leaf name { @@ -337,11 +423,11 @@ module infix-firewall { leaf-list ingress { type ifw:zone-ref; - description "List of zones traffic is entering. Use symbolic 'host' or 'any' as needed."; + description "List of zones traffic is entering. Use symbolic 'HOST' or 'ANY' as needed."; } leaf-list egress { - description "List of zones traffic is exiting from. Use symbolic 'host' or 'any' as needed."; + description "List of zones traffic is exiting from. Use symbolic 'HOST' or 'ANY' as needed."; type ifw:zone-ref; } @@ -359,6 +445,56 @@ module infix-firewall { type ifw-svc:well-known-service; } } + + container custom { + description "Custom filters, prioritized over other policy elements."; + + list filter { + description "Custom traffic filters with specific matching criteria. + + Evaluation order = list order."; + ordered-by user; + key "name"; + + leaf name { + description "Unique identifier for this filter within the policy."; + type ident; + } + + leaf family { + description "Address family selector."; + type enumeration { enum ipv4; enum ipv6; enum both; } + default both; + } + + choice type { + description "Type of traffic to match for this filter."; + + case icmp { + container icmp { + leaf type { + description "ICMP type to match."; + type icmp-type; + } + } + } + } + + leaf action { + description "How to handle filter matches."; + // XXX: Different from similar enums because we may add 'mark' later + type enumeration { enum accept; enum drop; enum reject; } + default accept; + } + + leaf priority { + // Sorting order as read from firewalld + description "Effective priority of this filter."; + config false; + type int16; + } + } + } } list service { diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index 52a047049..64c19e4ce 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -11,6 +11,80 @@ UNIT_TEST = False +def compress_interface_list(interfaces): + """Compress a list of interfaces into ranges where possible. + + Examples: + ['e1', 'e2', 'e3', 'e4'] -> 'e1-e4' + ['e1', 'e2', 'e4', 'e5'] -> 'e1-e2, e4-e5' + ['e1', 'e3', 'e5'] -> 'e1, e3, e5' + ['eth0', 'eth1', 'br0'] -> 'eth0-eth1, br0' + """ + if not interfaces: + return "" + + if len(interfaces) == 1: + return interfaces[0] + + # Group interfaces by their prefix (e.g., 'e', 'eth', 'br') + groups = {} + standalone = [] + + for iface in interfaces: + # Extract prefix and number using regex + match = re.match(r'^([a-zA-Z]+)(\d+)$', iface) + if match: + prefix = match.group(1) + number = int(match.group(2)) + if prefix not in groups: + groups[prefix] = [] + groups[prefix].append((number, iface)) + else: + # Interface doesn't follow prefix+number pattern + standalone.append(iface) + + # Process each group to find ranges + result_parts = [] + + for prefix in sorted(groups.keys()): + # Sort by number + numbers_and_ifaces = sorted(groups[prefix]) + ranges = [] + start = None + end = None + + for number, iface in numbers_and_ifaces: + if start is None: + # Start new range + start = number + end = number + elif number == end + 1: + # Extend current range + end = number + else: + # End current range and start new one + if start == end: + ranges.append(f"{prefix}{start}") + else: + ranges.append(f"{prefix}{start}-{prefix}{end}") + start = number + end = number + + # Add the final range + if start is not None: + if start == end: + ranges.append(f"{prefix}{start}") + else: + ranges.append(f"{prefix}{start}-{prefix}{end}") + + result_parts.extend(ranges) + + # Add standalone interfaces + result_parts.extend(sorted(standalone)) + + return ", ".join(result_parts) + + class Pad: iface = 16 proto = 11 @@ -1924,7 +1998,7 @@ def show_firewall_zone(json, zone_name=None): print(format_description('description', description)) print(f"{'name':<20}: {zone_name}") print(f"{'action':<20}: {action}") - print(f"{'interfaces':<20}: {', '.join(interfaces)}") + print(f"{'interfaces':<20}: {compress_interface_list(interfaces)}") print(f"{'networks':<20}: {', '.join(networks)}") print(f"{'forwarding':<20}: {forwarding}") print(f"{'services':<20}: {services_display}") @@ -2032,9 +2106,11 @@ def show_firewall_zone(json, zone_name=None): for zone in zones: name = zone.get('name', '') action = zone.get('action', 'reject') - interfaces = ", ".join(zone.get('interface', [])) - if not interfaces: + interface_list = zone.get('interface', []) + if not interface_list: interfaces = "(none)" + else: + interfaces = compress_interface_list(interface_list) services = ", ".join(zone.get('service', [])) if not services: if action == "accept": @@ -2069,6 +2145,9 @@ def show_firewall_policy(json, policy_name=None): masquerade = "yes" if policy.get('masquerade') else "no" description = policy.get('description', '') services = policy.get('service', []) + custom = policy.get('custom', {}) + custom_filters = custom.get('filter', []) + if policy == 'accept': services_display = "(all)" else: @@ -2081,6 +2160,19 @@ def show_firewall_policy(json, policy_name=None): print(f"{'action':<20}: {action}") print(f"{'masquerade':<20}: {masquerade}") print(f"{'services':<20}: {services_display}") + + if custom_filters: + print(f"{'custom filters':<20}: {len(custom_filters)} filter(s)") + for _, filter_entry in enumerate(custom_filters): + action = filter_entry.get('action', 'accept') + family = filter_entry.get('family', 'both') + + icmp = filter_entry.get('icmp') + if icmp: + icmp_type = icmp.get('type', 'unknown') + print(f"{' - ' + action:<6} {family} icmp-type {icmp_type}") + else: + print(f"{' - ' + action:<6} {family} (unknown type)") else: hdr = (f"{'':<{PadFirewall.policy_locked}}" f"{'NAME':<{PadFirewall.policy_name}}" @@ -2095,6 +2187,12 @@ def show_firewall_policy(json, policy_name=None): egress = ", ".join(policy.get('egress', [])) action = policy.get('action', 'reject') + # Check for custom filters + # custom = policy.get('custom', {}) + # custom_filters = custom.get('filter', []) + # if custom_filters: + # name += f" ({len(custom_filters)} filter(s))" + immutable = policy.get('immutable', False) locked = "⚷" if immutable else " " diff --git a/src/statd/python/yanger/infix_firewall.py b/src/statd/python/yanger/infix_firewall.py index 1ac9d0336..496a20911 100644 --- a/src/statd/python/yanger/infix_firewall.py +++ b/src/statd/python/yanger/infix_firewall.py @@ -7,6 +7,7 @@ --object-path /org/fedoraproject/FirewallD1 """ import dbus +import re from . import common @@ -177,6 +178,53 @@ def get_policy_data(fw, name): policy["masquerade"] = bool(settings.get('masquerade', 0)) + # Handle custom filters from rich_rules + custom_filters = [] + rich_rules = settings.get('rich_rules', []) + + for rule in rich_rules: + # Extract family (default to both if not specified) + family = "both" + if 'family="ipv4"' in rule: + family = "ipv4" + elif 'family="ipv6"' in rule: + family = "ipv6" + + icmp_type = None + action = None + + if 'icmp-type' in rule and 'name=' in rule: + name_match = re.search(r'.*name="([^"]+)"', rule) + if name_match: + icmp_type = name_match.group(1) + + action = "accept" + if ' drop' in rule: + action = "drop" + elif ' reject' in rule: + action = "reject" + elif 'icmp-block' in rule and 'name=' in rule: + name_match = re.search(r'.*name="([^"]+)"', rule) + if name_match: + icmp_type = name_match.group(1) + action = "reject" + + if icmp_type and action: + filter_entry = { + "name": f"icmp-{icmp_type}", + "family": family, + "action": action, + "icmp": { + "type": icmp_type + } + } + custom_filters.append(filter_entry) + + if custom_filters: + policy["custom"] = { + "filter": custom_filters + } + return policy except Exception as e: From 1ecab43d3ea2ac324a6495fbb0f18ab038f679fd Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 24 Aug 2025 21:25:34 +0200 Subject: [PATCH 47/55] Revert "confd: default firewall settings in factory-config" This reverts commit 626f724f629ad64fa3d03f6804f3b9f90bfd94a8. --- src/confd/share/factory.d/10-firewall.json | 19 ------------------- src/confd/share/factory.d/Makefile.am | 7 +++---- 2 files changed, 3 insertions(+), 23 deletions(-) delete mode 100644 src/confd/share/factory.d/10-firewall.json diff --git a/src/confd/share/factory.d/10-firewall.json b/src/confd/share/factory.d/10-firewall.json deleted file mode 100644 index bdff61a4f..000000000 --- a/src/confd/share/factory.d/10-firewall.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "infix-firewall:firewall": { - "default": "internal", - "zone": [ - { - "name": "internal", - "description": "Internal trusted network, forwarding between networks in same zone.", - "action": "accept", - "forwarding": true - }, - { - "name": "external", - "description": "External untrusted network, only SSH and DHCPv6 client allowed in.", - "action": "drop", - "service": [ "ssh", "dhcpv6-client" ] - } - ] - } -} diff --git a/src/confd/share/factory.d/Makefile.am b/src/confd/share/factory.d/Makefile.am index d8a69105e..bc9a9b8fe 100644 --- a/src/confd/share/factory.d/Makefile.am +++ b/src/confd/share/factory.d/Makefile.am @@ -1,4 +1,3 @@ -factorydir = $(pkgdatadir)/factory.d -dist_factory_DATA = 10-nacm.json 10-netconf-server.json \ - 10-infix-services.json 10-system.json \ - 10-firewall.json +factorydir = $(pkgdatadir)/factory.d +dist_factory_DATA = 10-nacm.json 10-netconf-server.json \ + 10-infix-services.json 10-system.json From 3fef431ba59edff43e7780c16164295294f815fa Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 24 Aug 2025 21:42:29 +0200 Subject: [PATCH 48/55] confd: allow services to set destination addr/len Signed-off-by: Joachim Wiberg --- src/confd/src/infix-firewall.c | 14 ++++---------- src/confd/yang/confd/infix-firewall.yang | 20 +++++++------------- 2 files changed, 11 insertions(+), 23 deletions(-) diff --git a/src/confd/src/infix-firewall.c b/src/confd/src/infix-firewall.c index 957204e0f..c4c6cfd1f 100644 --- a/src/confd/src/infix-firewall.c +++ b/src/confd/src/infix-firewall.c @@ -263,9 +263,7 @@ static int generate_zone(struct lyd_node *cfg, const char *name, char **ifaces) static int generate_service(struct lyd_node *cfg, const char *name) { const char *desc; -#if 0 /* ADVANCED FEATURE: destination variable for service destinations */ - const char *destination; -#endif + const char *dest; struct lyd_node *node; FILE *fp; @@ -274,19 +272,15 @@ static int generate_service(struct lyd_node *cfg, const char *name) return SR_ERR_SYS; desc = lydx_get_cattr(cfg, "description"); -#if 0 /* ADVANCED FEATURE: service destinations removed from YANG model */ - destination = lydx_get_cattr(cfg, "destination"); -#endif + dest = lydx_get_cattr(cfg, "destination"); fprintf(fp, "\n"); if (desc) fprintf(fp, " %s\n", desc); -#if 0 /* ADVANCED FEATURE: service destinations removed from YANG model */ - if (destination) - fprintf(fp, " \n", destination); -#endif + if (dest) + fprintf(fp, " \n", strchr(dest, ':') ? "6" : "4", dest); LYX_LIST_FOR_EACH(lyd_child(cfg), node, "port") { const char *lower = lydx_get_cattr(node, "lower"); diff --git a/src/confd/yang/confd/infix-firewall.yang b/src/confd/yang/confd/infix-firewall.yang index efcf99d93..a78aa3c1c 100644 --- a/src/confd/yang/confd/infix-firewall.yang +++ b/src/confd/yang/confd/infix-firewall.yang @@ -533,21 +533,15 @@ module infix-firewall { description "Layer 4 protocol."; type protocol-type; } - - /* ADVANCED FEATURE: Commented out for simplicity - rarely needed - leaf match-source { - description "Match on source port(s) instead of default: destination."; - type boolean; - } - */ } - /* ADVANCED FEATURE: Commented out for simplicity - service-specific destinations are complex - leaf destination { - type inet:ip-address; - description "Destination IP address/group to match this service to."; - } - */ + leaf destination { + type union { + type inet:ip-address; + type inet:ip-prefix; + } + description "Destination IP address/group to match this service to."; + } } leaf lockdown { From 2856c58f00c22bdf40644e597db8a710494d95a2 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 24 Aug 2025 21:45:32 +0200 Subject: [PATCH 49/55] confd: fix firewall zone and policy inference Turns out we never got any update events because the change callback ate them all. This commit fixes all that and adds policy inference now that we have rich rules supporting the subset of the allow-host-ipv6 policy. Signed-off-by: Joachim Wiberg --- package/confd/confd.mk | 1 + src/confd/src/infix-firewall.c | 103 ++++++++++++++++++++++++++++----- 2 files changed, 88 insertions(+), 16 deletions(-) diff --git a/package/confd/confd.mk b/package/confd/confd.mk index 4389b4d06..847b1c55f 100644 --- a/package/confd/confd.mk +++ b/package/confd/confd.mk @@ -113,6 +113,7 @@ define CONFD_CLEANUP rm -rf $(TARGET_DIR)/etc/firewall* rm -f $(TARGET_DIR)/usr/bin/firewall-applet rm -rf $(TARGET_DIR)/usr/share/firewalld + rm -f $(TARGET_DIR)/usr/lib/firewalld/policies/* find $(TARGET_DIR)/usr/lib/firewalld/zones -type f \ ! -name block.xml \ ! -name drop.xml \ diff --git a/src/confd/src/infix-firewall.c b/src/confd/src/infix-firewall.c index c4c6cfd1f..4e667b82c 100644 --- a/src/confd/src/infix-firewall.c +++ b/src/confd/src/infix-firewall.c @@ -399,10 +399,6 @@ static int infer_zone(sr_session_ctx_t *session, const char *name, const char *d DEBUG("Inferring zone %s (%s), action %s forwarding %d", name, desc, action, forwarding); - rc = srx_set_str(session, name, 0, XPATH "/zone[name='%s']/name", name); - if (rc) - return rc; - rc = srx_set_str(session, desc, 0, XPATH "/zone[name='%s']/description", name); if (rc) return rc; @@ -425,6 +421,67 @@ static int infer_zone(sr_session_ctx_t *session, const char *name, const char *d return SR_ERR_OK; } +static int infer_policy(sr_session_ctx_t *session, const char *name, const char *desc, + const char *action, const char *ingress[], const char *egress[], + const char *icmp_types[][4]) +{ + int rc; + + DEBUG("Inferring policy %s (%s), action %s", name, desc, action); + + rc = srx_set_str(session, desc, 0, XPATH "/policy[name='%s']/description", name); + if (rc) + return rc; + + rc = srx_set_str(session, action, 0, XPATH "/policy[name='%s']/action", name); + if (rc) + return rc; + + /* Set ingress zones */ + for (int i = 0; ingress && ingress[i]; i++) { + rc = srx_set_str(session, ingress[i], 0, XPATH "/policy[name='%s']/ingress[.='%s']", + name, ingress[i]); + if (rc) + return rc; + } + + /* Set egress zones */ + for (int i = 0; egress && egress[i]; i++) { + rc = srx_set_str(session, egress[i], 0, XPATH "/policy[name='%s']/egress[.='%s']", + name, egress[i]); + if (rc) + return rc; + } + + /* Set custom ICMP filters */ + for (int i = 0; icmp_types && icmp_types[i][0]; i++) { + const char *family = icmp_types[i][0]; + const char *filter = icmp_types[i][1]; + const char *action = icmp_types[i][2]; + const char *type = icmp_types[i][3]; + + rc = srx_set_str(session, family, 0, + XPATH "/policy[name='%s']/custom/filter[name='%s']/family", + name, filter); + if (rc) + return rc; + + rc = srx_set_str(session, action, 0, + XPATH "/policy[name='%s']/custom/filter[name='%s']/action", + name, filter); + if (rc) + return rc; + + rc = srx_set_str(session, type, 0, + XPATH "/policy[name='%s']/custom/filter[name='%s']/icmp/type", + name, filter); + if (rc) + return rc; + } + + return SR_ERR_OK; +} + static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module, const char *xpath, sr_event_t event, unsigned request_id, void *_confd) { @@ -565,37 +622,51 @@ static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module static int cand(sr_session_ctx_t *session, uint32_t sub_id, const char *module, const char *path, sr_event_t event, unsigned request_id, void *priv) { - const char *ext_svc[] = {"ssh", "dhcpv6-client", NULL}; - const char *int_svc[] = { NULL}; + const char *svc[] = {"ssh", "dhcpv6-client", NULL}; + const char *any[] = {"ANY", NULL}; + const char *host[] = {"HOST", NULL}; + const char *icmp_types[][4] = { + {"ipv6", "na", "accept", "neighbour-advertisement"}, + {"ipv6", "ns", "accept", "neighbour-solicitation"}, + {"ipv6", "ra", "accept", "router-advertisement"}, + {"ipv6", "re", "accept", "redirect"}, + {NULL, NULL, NULL, NULL} + }; size_t cnt = 0; int rc; if (event != SR_EV_UPDATE && event != SR_EV_CHANGE) return 0; + if (!srx_enabled(session, XPATH "/enabled")) { + DEBUG("Deleted, or not enabled, not inferring anything."); + return 0; + } + /* If unset, this is the first time we're called */ if (srx_get_str(session, XPATH "/default")) return 0; rc = srx_nitems(session, &cnt, XPATH "/zones"); - if (rc || cnt) { - WARN("firewall has zones defined %zu, but no default zone for new interfaces! (rc %d)", - cnt, rc); + if (rc == 0 || cnt) { + WARN("firewall has %zu zone(s) defined, but no default zone! (rc %d)", cnt, rc); return 0; } - rc = infer_zone(session, "external", "External untrusted network, only SSH and DHCPv6 client.", - "drop", true, ext_svc); + rc = infer_zone(session, "public", "Public, unknown network. Only SSH and DHCPv6 client allowed.", + "reject", false, svc); if (rc) return rc; - rc = infer_zone(session, "internal", "Internal trusted network, forwarding between networks.", - "accept", true, int_svc); + /* Set up default zone for new networks */ + rc = srx_set_str(session, "public", 0, XPATH "/default"); if (rc) return rc; - /* Set up default zone for new networks */ - rc = srx_set_str(session, "internal", 0, XPATH "/default"); + /* Infer allow-host-ipv6 policy */ + rc = infer_policy(session, "allow-host-ipv6", + "Allows basic IPv6 functionality for the host.", + "continue", any, host, icmp_types); if (rc) return rc; @@ -623,7 +694,7 @@ int infix_firewall_init(struct confd *confd) { int rc; - REGISTER_CHANGE(confd->session, MODULE, XPATH, 0, change, confd, &confd->sub); + REGISTER_CHANGE(confd->session, MODULE, XPATH "//.", 0, change, confd, &confd->sub); REGISTER_CHANGE(confd->cand, MODULE, XPATH "//.", SR_SUBSCR_UPDATE, cand, confd, &confd->sub); REGISTER_RPC(confd->session, XPATH "/lockdown-mode", lockdown, NULL, &confd->sub); From fa8232d79ce3ecdb3d2bb18b68ae2237ad960841 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 24 Aug 2025 21:48:05 +0200 Subject: [PATCH 50/55] confd: restore 'enabled' setting to firewall Signed-off-by: Joachim Wiberg --- src/confd/src/infix-firewall.c | 7 +++++++ src/confd/yang/confd/infix-firewall.yang | 5 +++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/confd/src/infix-firewall.c b/src/confd/src/infix-firewall.c index 4e667b82c..0c94c41e4 100644 --- a/src/confd/src/infix-firewall.c +++ b/src/confd/src/infix-firewall.c @@ -603,6 +603,13 @@ static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module if (reload_needed) system("initctl -nbq touch firewalld"); + + /* We check 'enabled' here to allow us to debug generated files. */ + if (!lydx_is_enabled(global, "enabled")) { + global = NULL; + goto done; + } + done: systemf("initctl -nbq %s firewalld", global ? "enable" : "disable"); diff --git a/src/confd/yang/confd/infix-firewall.yang b/src/confd/yang/confd/infix-firewall.yang index a78aa3c1c..c53801b7f 100644 --- a/src/confd/yang/confd/infix-firewall.yang +++ b/src/confd/yang/confd/infix-firewall.yang @@ -254,7 +254,7 @@ module infix-firewall { container firewall { description "Zone-based firewall configuration."; presence "Activte firewall."; -/* + leaf enabled { description "Enable or disable the firewall. @@ -263,8 +263,9 @@ module infix-firewall { remember to re-enable when done, and maybe remove connections to the Internet before disabling."; type boolean; + default true; } -*/ + leaf default { description "Default zone for interfaces. From f857632fd34483a1eb191400de1d1f8a762f8c2c Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 24 Aug 2025 21:51:24 +0200 Subject: [PATCH 51/55] doc: mention firewall symbolic names for zones and custom filters Signed-off-by: Joachim Wiberg --- doc/firewall.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/doc/firewall.md b/doc/firewall.md index ac0f0f59a..258cc1438 100644 --- a/doc/firewall.md +++ b/doc/firewall.md @@ -92,6 +92,18 @@ DESCRIPTION admin@example:/config/firewall/policy/lan-to-dmz/> ``` +### Symbolic Names + +The symbolic names `HOST` and `ANY` are available for use in both `ingress` +and `egress` zones. In fact, the CLI uses inference when first enabling the +firewall to inject a default policy to allow an IPv6 autoconf address. + +### Custom Filters + +For more advanced firewall scenarios *custom filters* can be used. The only +support currently are various ICMP type traffic control. Enough to support +the default `allow-host-ipv6` policy. + ## Services Several pre-defined services exist, that cover most use-cases, but you can From 829505574a465da9bb046a6f00412a7f1a4bb296 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 24 Aug 2025 22:29:27 +0200 Subject: [PATCH 52/55] confd: sort policy rules according to 'ordered-by user;' The firewalld policy rules, including rich rules, have this obnoxious priority field which is extremely hard to get right, so in Infix we use the far superior YANG construct 'ordered-by user;'. This commit ensure all rules are generated in that order by setting the priority field, on read-back from firewalld (operational) this priority field is used to sort the output of rules in the CLI. Signed-off-by: Joachim Wiberg --- src/confd/src/infix-firewall.c | 35 ++++++++++++++++++----- src/confd/yang/confd/infix-firewall.yang | 7 +++++ src/statd/python/cli_pretty/cli_pretty.py | 8 ++++-- src/statd/python/yanger/infix_firewall.py | 17 ++++++++--- 4 files changed, 54 insertions(+), 13 deletions(-) diff --git a/src/confd/src/infix-firewall.c b/src/confd/src/infix-firewall.c index 0c94c41e4..7ea5dda6b 100644 --- a/src/confd/src/infix-firewall.c +++ b/src/confd/src/infix-firewall.c @@ -298,13 +298,18 @@ static int generate_service(struct lyd_node *cfg, const char *name) return close_file(fp); } -static int generate_policy(struct lyd_node *cfg, const char *name) +static int generate_policy(struct lyd_node *cfg, const char *name, int *priority) { const char *desc, *action; struct lyd_node *node; bool masquerade; FILE *fp; + if (*priority > 0) { + ERROR("Too many policies/filters - exceeded int16 range"); + return SR_ERR_SYS; + } + fp = open_file(FIREWALLD_POLICIES_DIR, name); if (!fp) return SR_ERR_SYS; @@ -313,7 +318,8 @@ static int generate_policy(struct lyd_node *cfg, const char *name) action = lydx_get_cattr(cfg, "action"); masquerade = lydx_is_enabled(cfg, "masquerade"); - fprintf(fp, "\n", policy_action_to_target(action)); + fprintf(fp, "\n", + policy_action_to_target(action), (*priority)++); if (desc) fprintf(fp, " %s\n", desc); @@ -336,10 +342,18 @@ static int generate_policy(struct lyd_node *cfg, const char *name) const char *family = lydx_get_cattr(filter, "family"); struct lyd_node *icmp; + if (*priority > 0) { + ERROR("Too many policies/filters - exceeded int16 range"); + close_file(fp); + delete_file(FIREWALLD_POLICIES_DIR, name); + return SR_ERR_SYS; + } + if (strcmp(family, "both")) - fprintf(fp, " \n", family); + fprintf(fp, " \n", + family, (*priority)++); else - fprintf(fp, " \n"); + fprintf(fp, " \n", (*priority)++); action = lydx_get_cattr(filter, "action"); icmp = lydx_get_descendant(filter, "filter", "icmp", NULL); @@ -541,6 +555,7 @@ static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module if (lydx_get_descendant(diff, "firewall", NULL)) { const char *default_zone = lydx_get_cattr(global, "default"); struct lyd_node *list, *node; + int priority = -32768; /* First, handle explicit deletions by removing files */ list = lydx_get_descendant(diff, "firewall", "zone", NULL); @@ -592,10 +607,16 @@ static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module LYX_LIST_FOR_EACH(clist, cnode, "service") generate_service(cnode, lydx_get_cattr(cnode, "name")); - /* Regenerate all policies */ + /* Regenerate all policies with sequential priority allocation */ clist = lydx_get_descendant(tree, "firewall", "policy", NULL); - LYX_LIST_FOR_EACH(clist, cnode, "policy") - generate_policy(cnode, lydx_get_cattr(cnode, "name")); + LYX_LIST_FOR_EACH(clist, cnode, "policy") { + const char *name = lydx_get_cattr(cnode, "name"); + + if (generate_policy(cnode, name, &priority)) { + ERROR("Failed to generate policy %s", name); + goto err_release_data; + } + } reload_needed = true; } diff --git a/src/confd/yang/confd/infix-firewall.yang b/src/confd/yang/confd/infix-firewall.yang index c53801b7f..296813ccb 100644 --- a/src/confd/yang/confd/infix-firewall.yang +++ b/src/confd/yang/confd/infix-firewall.yang @@ -496,6 +496,13 @@ module infix-firewall { } } } + + leaf priority { + // Sorting order as read from firewalld + description "Effective priority of this filter."; + config false; + type int16; + } } list service { diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index 64c19e4ce..6ecc539f9 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -2163,7 +2163,9 @@ def show_firewall_policy(json, policy_name=None): if custom_filters: print(f"{'custom filters':<20}: {len(custom_filters)} filter(s)") - for _, filter_entry in enumerate(custom_filters): + + sorted_filters = sorted(custom_filters, key=lambda f: f.get('priority', 32767)) + for _, filter_entry in enumerate(sorted_filters): action = filter_entry.get('action', 'accept') family = filter_entry.get('family', 'both') @@ -2181,7 +2183,9 @@ def show_firewall_policy(json, policy_name=None): f"{'EGRESS':<{PadFirewall.policy_egress}}") Decore.title("Policies", len(hdr)) print(Decore.invert(hdr)) - for policy in policies: + + sorted_policies = sorted(policies, key=lambda p: p.get('priority', 32767)) + for policy in sorted_policies: name = policy.get('name', '') ingress = ", ".join(policy.get('ingress', [])) egress = ", ".join(policy.get('egress', [])) diff --git a/src/statd/python/yanger/infix_firewall.py b/src/statd/python/yanger/infix_firewall.py index 496a20911..72fd9c302 100644 --- a/src/statd/python/yanger/infix_firewall.py +++ b/src/statd/python/yanger/infix_firewall.py @@ -139,7 +139,7 @@ def get_policy_data(fw, name): policy = { "name": name, "action": "reject", - # "priority": 32767, + "priority": 32767, "ingress": [], "egress": [] } @@ -153,9 +153,9 @@ def get_policy_data(fw, name): } policy["action"] = action.get(target, "reject") - # priority = settings.get('priority', 32767) - # if isinstance(priority, int): - # policy["priority"] = priority + priority = settings.get('priority', 32767) + if isinstance(priority, int): + policy["priority"] = priority description = settings.get('description', '') if description: @@ -192,6 +192,14 @@ def get_policy_data(fw, name): icmp_type = None action = None + prio = -1 + + if 'priority' in rule: + prio_match = re.search(r'.*priority=([^ ]+)', rule) + if prio_match: + val = prio_match.group(1) + if isinstance(val, int): + prio = val if 'icmp-type' in rule and 'name=' in rule: name_match = re.search(r'.*name="([^"]+)"', rule) @@ -212,6 +220,7 @@ def get_policy_data(fw, name): if icmp_type and action: filter_entry = { "name": f"icmp-{icmp_type}", + "priority": prio, "family": family, "action": action, "icmp": { From 96b84506165d2fcddd4f0d033a5b21316495db42 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 24 Aug 2025 22:33:55 +0200 Subject: [PATCH 53/55] doc: update fw TODOs, what's left? Signed-off-by: Joachim Wiberg --- doc/TODO.org | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/TODO.org b/doc/TODO.org index a0e93168f..f4c9d6e6e 100644 --- a/doc/TODO.org +++ b/doc/TODO.org @@ -18,16 +18,16 @@ show deny for the same zone-to-zone communication - [X] We should show the implicit rules for communicating with the HOST - [X] Investigate "padlock" on built-in policys (and zones?) and expose more? -- [ ] Document established,related somewhere, fixed/padlocked policy? Also, +- [X] Document established,related somewhere, fixed/padlocked policy? Also, document why this is a good idea to always have enabled. See RH docs. - [ ] Podman published ports, - [ ] Software fastpath -- [ ] Allow overriding/editing immutable policies and zones +- +[ ] Allow overriding/editing immutable policies and zones+ - [X] Add tests: basic (end device), wan-lan, wan-lan-dmz, +hammer (stress)+ -- [ ] Add documentation +- [X] Add documentation - See - Add some tool tips: nc, nmap, ping, and socat to stress the firewall -- [ ] Fix inference so we can remove defaults from factory-config! +- [X] Fix inference so we can remove defaults from factory-config! * TODO doc: User Guide From 631b205850710c6261399221911a5c1b402a4918 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 24 Aug 2025 22:45:24 +0200 Subject: [PATCH 54/55] test: fix TypeError: non-ordered lists cannot be accessed by index Signed-off-by: Joachim Wiberg --- test/case/infix_firewall/wan-dmz-lan/test.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/case/infix_firewall/wan-dmz-lan/test.py b/test/case/infix_firewall/wan-dmz-lan/test.py index 68de54364..f68a77c9c 100755 --- a/test/case/infix_firewall/wan-dmz-lan/test.py +++ b/test/case/infix_firewall/wan-dmz-lan/test.py @@ -181,7 +181,8 @@ def debug(msg): assert wan_zone["action"] == "drop" assert wan_if in wan_zone["interface"] assert len(wan_zone["port-forward"]) == 1 - pf = wan_zone["port-forward"][0] + # Access port-forward by iterating over the keyed list + pf = next(iter(wan_zone["port-forward"])) assert pf["lower"] == 8080 assert pf["to"]["addr"] == DMZ_SERVER_IP assert pf["to"]["port"] == 80 From 422e8127dc1ec383a7e58af8f76fb7a74a5a5b8b Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 25 Aug 2025 08:07:44 +0200 Subject: [PATCH 55/55] test: simplify and skip UNIX backup files - Drop 'local', not available in POSIX shell scripts - Check for an assortment of backup file combos - Simplify nested if-statements, skip whitelist first Signed-off-by: Joachim Wiberg --- test/case/repo/defconfig.sh | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/test/case/repo/defconfig.sh b/test/case/repo/defconfig.sh index e8d0768a3..abd0f16cd 100755 --- a/test/case/repo/defconfig.sh +++ b/test/case/repo/defconfig.sh @@ -23,21 +23,25 @@ disabled_root_login() # For all defconfigs check() { - local total=$# - local num=1 - local base= + total=$# + num=1 + base= echo "1..$total" for defconfig in "$@"; do + # Skip UNIX backup files + case "$defconfig" in + *~|*.bak|'#'*'#'|.#*) + continue + ;; + esac base=$(basename "$defconfig") - if disabled_root_login "$defconfig"; then + if whitelist "$base"; then + echo "ok $num - $base is exempted # skip" + elif disabled_root_login "$defconfig"; then echo "ok $num - $base disables root logins" else - if whitelist "$base"; then - echo "ok $num - $base is exempted # skip" - else - echo "not ok $num - $base has not disabled root login" - fi + echo "not ok $num - $base has not disabled root login" fi num=$((num + 1)) done