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 00000000..18581c05 --- /dev/null +++ b/board/common/rootfs/etc/finit.d/available/firewalld.conf @@ -0,0 +1,3 @@ +service [2345] reload:'firewall-cmd -q --reload' \ + firewalld --nofork --log-target syslog \ + -- Firewall daemon 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 00000000..d3ab25c5 --- /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/board/common/rootfs/usr/bin/yorn b/board/common/rootfs/usr/bin/yorn index 60b1b37a..cd4805e6 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/configs/aarch64_defconfig b/configs/aarch64_defconfig index 48b9d402..08faab2e 100644 --- a/configs/aarch64_defconfig +++ b/configs/aarch64_defconfig @@ -73,6 +73,7 @@ BR2_PACKAGE_CONNTRACK_TOOLS=y BR2_PACKAGE_DNSMASQ=y BR2_PACKAGE_ETHTOOL=y BR2_PACKAGE_FPING=y +BR2_PACKAGE_FIREWALL=y BR2_PACKAGE_FRR=y # BR2_PACKAGE_IFUPDOWN_SCRIPTS is not set BR2_PACKAGE_IPERF3=y diff --git a/configs/aarch64_minimal_defconfig b/configs/aarch64_minimal_defconfig index 24c44e11..44e62307 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_FIREWALL=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 6fec332f..64177be5 100644 --- a/configs/r2s_defconfig +++ b/configs/r2s_defconfig @@ -91,6 +91,7 @@ BR2_PACKAGE_CONNTRACK_TOOLS=y BR2_PACKAGE_DNSMASQ=y BR2_PACKAGE_ETHTOOL=y BR2_PACKAGE_FPING=y +BR2_PACKAGE_FIREWALL=y BR2_PACKAGE_FRR=y # BR2_PACKAGE_IFUPDOWN_SCRIPTS is not set BR2_PACKAGE_IPERF3=y diff --git a/configs/riscv64_defconfig b/configs/riscv64_defconfig index 42f2cc34..b69eb726 100644 --- a/configs/riscv64_defconfig +++ b/configs/riscv64_defconfig @@ -86,6 +86,7 @@ BR2_PACKAGE_CONNTRACK_TOOLS=y BR2_PACKAGE_DNSMASQ=y BR2_PACKAGE_ETHTOOL=y BR2_PACKAGE_FPING=y +BR2_PACKAGE_FIREWALL=y BR2_PACKAGE_FRR=y # BR2_PACKAGE_IFUPDOWN_SCRIPTS is not set BR2_PACKAGE_IPERF3=y diff --git a/configs/x86_64_defconfig b/configs/x86_64_defconfig index 6cf6e63c..78fb4ec0 100644 --- a/configs/x86_64_defconfig +++ b/configs/x86_64_defconfig @@ -71,6 +71,7 @@ BR2_PACKAGE_CONNTRACK_TOOLS=y BR2_PACKAGE_DNSMASQ=y BR2_PACKAGE_ETHTOOL=y BR2_PACKAGE_FPING=y +BR2_PACKAGE_FIREWALL=y BR2_PACKAGE_FRR=y # BR2_PACKAGE_IFUPDOWN_SCRIPTS is not set BR2_PACKAGE_IPERF3=y diff --git a/configs/x86_64_minimal_defconfig b/configs/x86_64_minimal_defconfig index 5fe8ac81..82c09f98 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_FIREWALL=y BR2_PACKAGE_FRR=y # BR2_PACKAGE_IFUPDOWN_SCRIPTS is not set BR2_PACKAGE_IPROUTE2=y diff --git a/doc/TODO.org b/doc/TODO.org index f05fe9f9..7e7840ce 100644 --- a/doc/TODO.org +++ b/doc/TODO.org @@ -1,3 +1,53 @@ +* TODO Add support for firewall +- [X] All "implicit" policies in zones are now policies: intra- and inter-zone policies +- [X] Add locked rules for implicit drop/reject policy as last rule in policy (ANY, ANY) +- [X] Firewall logs should show IN=iface (IIF) before SOURCE +- [X] Interfaces are not defaulting to the default zone, must handle bridge + ports and changes to enslavement, so regenerate every time is a must! +- [ ] firewalld helpers -- possibly for conntrack, e.g., ftp +- [ ] With =modprobe br_netfilter= firewalld would see *all* traffic, but there are + issues, , and limits to what + seem to be possible atm. You may also need to enable these callbacks: + =echo 1 | sudo tee /proc/sys/net/bridge/bridge-nf-call-iptables= + =echo 1 | sudo tee /proc/sys/net/bridge/bridge-nf-call-ip6tables= + =echo 1 | sudo tee /proc/sys/net/bridge/bridge-nf-call-arptables= +- [X] Add missing upper to port forward since port ranges are supported, see services! +- [X] =[do] show firewall= not available yet +- [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! +- [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! +- [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 +- [X] We should show the implicit rules for communicating with the HOST +- [X] Investigate "padlock" on built-in policys (and zones?) and expose more? +- [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+ +- [X] Add tests: basic (end device), wan-lan, wan-lan-dmz, +hammer (stress)+ +- [X] Add documentation + - See + - Add some tool tips: nc, nmap, ping, and socat to stress the firewall +- [X] Fix inference so we can remove defaults from factory-config! +- [X] Add iperf service +- [X] Add nftables ownership=yes setting, introduced in later firewalld versions +- [ ] Investigate fail2ban integration with firewalld, for more info, see: + https://github.com/firewalld/firewalld/issues/1466#issuecomment-2773130569 +- [ ] Update screenshots for documentation +- [ ] Review both cli-pretty and yanger code +- [ ] Review default-zone handling (needed?) +- [ ] Clean up =INFER_POLICY= ifdefs +- [ ] Revisit built-in fallback zones (public, block, drop) + * TODO doc: User Guide - Feature set and scope, e.g. diff --git a/package/Config.in b/package/Config.in index 67c17a30..90636287 100644 --- a/package/Config.in +++ b/package/Config.in @@ -15,6 +15,7 @@ source "$BR2_EXTERNAL_INFIX_PATH/package/statd/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/factory/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/faux/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/finit/Config.in" +source "$BR2_EXTERNAL_INFIX_PATH/package/firewall/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/greenpak-programmer/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/ifupdown-ng/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/iito/Config.in" diff --git a/package/firewall/Config.in b/package/firewall/Config.in new file mode 100644 index 00000000..6be1e71c --- /dev/null +++ b/package/firewall/Config.in @@ -0,0 +1,8 @@ +config BR2_PACKAGE_FIREWALL + bool "firewall" + select BR2_PACKAGE_FIREWALLD + help + Meta pacakge to select firewall related packages for the OS + as well as adapt and integrate properly with the system. + + https://github.com/kernelkit/infix diff --git a/package/firewall/finalize.sh b/package/firewall/finalize.sh new file mode 100755 index 00000000..c5d5fa61 --- /dev/null +++ b/package/firewall/finalize.sh @@ -0,0 +1,90 @@ +#!/bin/sh +set -e + +TARGET_DIR="$1" +FIREWALL_SERVICES_YANG="$2" +FIREWALL_DAEMON_DIR="${TARGET_DIR}/usr/lib/firewalld" + +# Cleanup — remove unnecessary firewalld files and create required directories +cleanup() +{ + rm -rf "${TARGET_DIR}/etc/firewall"* + rm -f "${TARGET_DIR}/usr/bin/firewall-applet" + rm -rf "${TARGET_DIR}/usr/share/firewalld" + + # Keep only the three zones required by firewalld (core/fw.py) + find "${FIREWALL_DAEMON_DIR}/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" + mkdir -p "${FIREWALL_DAEMON_DIR}/services" +} + +# Prune services — keep only those that match YANG enums +prune_services() +{ + if [ ! -f "${FIREWALL_SERVICES_YANG}" ]; then + echo "ERROR: ${FIREWALL_SERVICES_YANG} not found" + exit 1 + fi + + # Extract enum values from YANG model + ENUMS=$(grep 'enum "' "${FIREWALL_SERVICES_YANG}" | \ + sed 's/.*enum "\([^"]*\)".*/\1/') + + # Validate that all YANG enums have corresponding .xml files + MISSING=0 + for service in ${ENUMS}; do + if [ ! -f "${FIREWALL_DAEMON_DIR}/services/${service}.xml" ]; then + echo "Service ${service} is not a known firewalld service" + MISSING=1 + fi + done + + if [ ${MISSING} -eq 1 ]; then + exit 1 + fi + + # Remove .xml files that are not in YANG enums + cd "${FIREWALL_DAEMON_DIR}/services/" + for xmlfile in *.xml; do + service="${xmlfile%.xml}" + if ! echo "${ENUMS}" | grep -q "^${service}$"; then + rm "${xmlfile}" + fi + done +} + +# Mark built-in zones and policies as immutable +mark_builtins() +{ + FIREWALL_XML_FILES="${FIREWALL_DAEMON_DIR}/policies/*.xml ${FIREWALL_DAEMON_DIR}/zones/*.xml" + + for xmlfile in ${FIREWALL_XML_FILES}; do + [ -f "${xmlfile}" ] || continue + grep -q "(immutable)" "${xmlfile}" && continue + + 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 +} + +cleanup +prune_services +mark_builtins diff --git a/package/firewall/firewall.mk b/package/firewall/firewall.mk new file mode 100644 index 00000000..94a344f6 --- /dev/null +++ b/package/firewall/firewall.mk @@ -0,0 +1,25 @@ +################################################################################ +# +# Firewall support +# +################################################################################ + +FIREWALL_PACKAGE_VERSION = 1.0 +FIREWALL_PACKAGE_LICENSE = MIT +FIREWALL_DEPENDENCIES = firewalld +FIREWALL_SERVICES_YANG = $(CONFD_SRCDIR)/yang/confd/infix-firewall-services.yang + +# Copy custom service definitions and run finalization script +define FIREWALL_INSTALL_CUSTOM_SERVICES + mkdir -p $(TARGET_DIR)/usr/lib/firewalld/services + cp $(FIREWALL_PKGDIR)/services/*.xml $(TARGET_DIR)/usr/lib/firewalld/services/ +endef + +define FIREWALL_FINALIZE + $(FIREWALL_PKGDIR)/finalize.sh $(TARGET_DIR) $(FIREWALL_SERVICES_YANG) +endef + +FIREWALL_POST_INSTALL_TARGET_HOOKS += FIREWALL_INSTALL_CUSTOM_SERVICES +FIREWALL_TARGET_FINALIZE_HOOKS += FIREWALL_FINALIZE + +$(eval $(generic-package)) diff --git a/package/firewall/services/netconf.xml b/package/firewall/services/netconf.xml new file mode 100644 index 00000000..3a403972 --- /dev/null +++ b/package/firewall/services/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/firewall/services/restconf.xml b/package/firewall/services/restconf.xml new file mode 100644 index 00000000..205e9fc6 --- /dev/null +++ b/package/firewall/services/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/patches/firewalld/2.0.2/silence-warnings-about-old-backends.patch b/patches/firewalld/2.0.2/silence-warnings-about-old-backends.patch new file mode 100644 index 00000000..944f235c --- /dev/null +++ b/patches/firewalld/2.0.2/silence-warnings-about-old-backends.patch @@ -0,0 +1,40 @@ +diff --git a/src/firewall/core/fw.py b/src/firewall/core/fw.py +index 0a51cfff..8afd6113 100644 +--- a/src/firewall/core/fw.py ++++ b/src/firewall/core/fw.py +@@ -223,7 +223,7 @@ class Firewall(object): + self.ipset_backend.set_list() + except ValueError: + if self.nftables_enabled: +- log.info1("ipset not usable, disabling ipset usage in firewall. Other set backends (nftables) remain usable.") ++ log.debug1("ipset not usable, disabling ipset usage in firewall. Other set backends (nftables) remain usable.") + else: + log.warning("ipset not usable, disabling ipset usage in firewall.") + self.ipset_supported_types = [ ] +@@ -240,7 +240,7 @@ class Firewall(object): + "individual calls for IPv4 firewall.") + else: + if self.nftables_enabled: +- log.info1("iptables-restore and iptables are missing, " ++ log.debug1("iptables-restore and iptables are missing, " + "IPv4 direct rules won't be usable.") + else: + log.warning("iptables-restore and iptables are missing, " +@@ -260,7 +260,7 @@ class Firewall(object): + "individual calls for IPv6 firewall.") + else: + if self.nftables_enabled: +- log.info1("ip6tables-restore and ip6tables are missing, " ++ log.debug1("ip6tables-restore and ip6tables are missing, " + "IPv6 direct rules won't be usable.") + else: + log.warning("ip6tables-restore and ip6tables are missing, " +@@ -280,7 +280,7 @@ class Firewall(object): + "individual calls for bridge firewall.") + else: + if self.nftables_enabled: +- log.info1("ebtables-restore and ebtables are missing, " ++ log.debug1("ebtables-restore and ebtables are missing, " + "eb direct rules won't be usable.") + else: + log.warning("ebtables-restore and ebtables are missing, " diff --git a/src/confd/bin/Makefile.am b/src/confd/bin/Makefile.am index 925e5130..49bed009 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 00000000..055b9096 --- /dev/null +++ b/src/confd/bin/firewall @@ -0,0 +1,403 @@ +#!/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" +VERBOSE=0 + +print() { + if [ "$VERBOSE" -eq 1 ]; then + printf '%s\n' "$*" + fi +} + +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 + print "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 + + print "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 + print "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 + print "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 + print "Lockdown mode: ACTIVE" + return 0 + fi + + print "Lockdown mode: INACTIVE" + return 1 +} + +show_status() +{ + echo "=== Firewall Status ===" + + if check_firewalld; then + echo " Firewalld : RUNNING" + else + echo "Firewalld NOT RUNNING" + return 1 + fi + + if is_panic_enabled; then + panic="on" + else + panic="off" + fi + echo " Lockdown Mode : $panic" + + 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 + + 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 "'" '"' | sed 's/@as \[\]/[]/g') + + if echo "$zones_output" | jq -e . >/dev/null 2>&1; then + echo "$zones_output" | jq -r 'to_entries[] | + " \(.key):" + + (if (.value.interfaces | length) > 0 then "\n Interfaces: " + (.value.interfaces | join(", ")) else "" end) + + (if (.value.sources | length) > 0 then "\n Networks : " + (.value.sources | join(", ")) else "" end) + + (if (.value.interfaces | length) == 0 and (.value.sources | length) == 0 then "\n Members : (none)" else "" end) + ' 2>/dev/null || echo " Failed to parse zones" + else + echo " No zones or failed to retrieve" + fi + echo + + 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 + + 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" + + 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 + target=$(echo "$policy_settings" | grep -o "'target': <'[^']*'" | cut -d"'" -f4) + description=$(echo "$policy_settings" | grep -o "'description': <'[^']*'" | cut -d"'" -f4) + masquerade=$(echo "$policy_settings" | grep -o "'masquerade': <[^>]*>" | sed "s/.*<\([^>]*\)>.*/\1/") + priority=$(echo "$policy_settings" | grep -o "'priority': <[^>]*>" | sed "s/.*<\([^>]*\)>.*/\1/") + 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 + + 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 + + if echo "$policy_settings" | grep -q "'rich_rules'"; then + rich_rules=$(echo "$policy_settings" | grep -o "'rich_rules': <\[[^]]*\]>" | sed "s/'rich_rules': <\[//; s/\]>//") + fi + + # Extract port forwarding information + if echo "$policy_settings" | grep -q "'forward_ports'"; then + forward_ports=$(echo "$policy_settings" | grep -o "'forward_ports': <\[[^]]*\]>" | sed "s/'forward_ports': <\[//; 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}" + + 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 + + # Display port forwarding rules + if [ -n "$forward_ports" ] && [ "$forward_ports" != "" ]; then + echo " Port FWD :" + # Parse forward_ports which contains tuples like ('8080', 'tcp', '80', '10.0.1.100') + # Extract individual port forward entries + echo "$forward_ports" | sed "s/), (/\n/g" | sed "s/^(//; s/)$//" | while IFS= read forward_rule; do + if [ -n "$forward_rule" ]; then + # Parse the tuple: 'from_port', 'protocol', 'to_port', 'to_addr' + from_port=$(echo "$forward_rule" | cut -d',' -f1 | sed "s/'//g" | tr -d ' ') + protocol=$(echo "$forward_rule" | cut -d',' -f2 | sed "s/'//g" | tr -d ' ') + to_port=$(echo "$forward_rule" | cut -d',' -f3 | sed "s/'//g" | tr -d ' ') + to_addr=$(echo "$forward_rule" | cut -d',' -f4 | sed "s/'//g" | tr -d ' ') + + if [ -n "$from_port" ] && [ -n "$protocol" ] && [ -n "$to_addr" ] && [ -n "$to_port" ]; then + echo " ${from_port}/${protocol} → ${to_addr}:${to_port}" + else + echo " $forward_rule (unparsed)" + fi + fi + done + else + echo " Port FWD : 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) + -v, --verbose Enable verbose output for error messages and status + -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() +{ + wait_timeout="" + + if ! parsed_args=$(getopt -o hv --long wait:,help,verbose -- "$@"); then + echo "Error parsing options" >&2 + exit 1 + fi + + eval set -- "$parsed_args" + + while true; do + case "$1" in + --wait) + wait_timeout="$2" + shift 2 + ;; + -v|--verbose) + VERBOSE=1 + shift + ;; + -h|--help) + show_help + exit 0 + ;; + --) + shift + break + ;; + *) + echo "Error: Unknown option '$1'" >&2 + exit 1 + ;; + esac + done + + case "${1:-}" in + reload) + if ! check_firewalld; then + echo "Error: firewalld is not running or does not respond!" >&2 + exit 1 + fi + + if ! call_reload; then + exit 1 + fi + + if [ -n "$wait_timeout" ]; then + if ! wait_for_reload "$wait_timeout"; then + echo "Firewall reload timed out" >&2 + exit 1 + fi + fi + ;; + panic) + if ! check_firewalld; then + echo "Error: firewalld is not running or does not respond" >&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 "$@" diff --git a/src/confd/src/Makefile.am b/src/confd/src/Makefile.am index 87beb592..29466e99 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 ba987f4b..49fb59fe 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 ffe08309..90595595 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 00000000..7223ed15 --- /dev/null +++ b/src/confd/src/infix-firewall.c @@ -0,0 +1,740 @@ +/* SPDX-License-Identifier: BSD-3-Clause */ + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "core.h" +#include "ietf-interfaces.h" + +#define MODULE "infix-firewall" +#define XPATH "/infix-firewall:firewall" +#define INFER_POLICY 0 +#define FIREWALLD_DIR "/etc/firewalld" +#define FIREWALLD_DIR_NEXT "/etc/firewalld+" +#define FIREWALLD_CONF FIREWALLD_DIR_NEXT "/firewalld.conf" +#define FIREWALLD_ZONES_DIR FIREWALLD_DIR_NEXT "/zones" +#define FIREWALLD_SERVICES_DIR FIREWALLD_DIR_NEXT "/services" +#define FIREWALLD_POLICIES_DIR FIREWALLD_DIR_NEXT "/policies" + +static struct { + const char *yang; + const char *target; +} zone_action_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_action_to_target(const char *action) +{ + 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_action_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 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; 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) +{ + 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(struct lyd_node *cfg, const char *name, char **ifaces) +{ + const char *action, *desc; + struct lyd_node *node; + FILE *fp; + + fp = open_file(FIREWALLD_ZONES_DIR, name); + if (!fp) + return SR_ERR_SYS; + + action = lydx_get_cattr(cfg, "action"); + desc = lydx_get_cattr(cfg, "description"); + + fprintf(fp, "\n", zone_action_to_target(action)); + fprintf(fp, " %s\n", name); + + if (desc) + fprintf(fp, " %s\n", desc); + + LYX_LIST_FOR_EACH(lyd_child(cfg), node, "interface") + fprintf(fp, " \n", lyd_get_value(node)); + + if (ifaces) { + for (int i = 0; ifaces[i]; i++) { + if (ifaces[i][0] != '\0') { + fprintf(fp, " \n", ifaces[i]); + } + } + + log_unzoned(name, ifaces); + } + + LYX_LIST_FOR_EACH(lyd_child(cfg), node, "network") + fprintf(fp, " \n", lyd_get_value(node)); + + LYX_LIST_FOR_EACH(lyd_child(cfg), node, "service") + fprintf(fp, " \n", lyd_get_value(node)); + + LYX_LIST_FOR_EACH(lyd_child(cfg), node, "port-forward") { + 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"); + + if (to) { + const char *to_addr = lydx_get_cattr(to, "addr"); + const char *to_port = lydx_get_cattr(to, "port"); + + if (upper) { + /* Port range */ + fprintf(fp, " \n"); + } else { + /* Single port */ + fprintf(fp, " \n"); + } + } + } + + fprintf(fp, "\n"); + + return close_file(fp); +} + +static int generate_service(struct lyd_node *cfg, const char *name) +{ + const char *desc; + const char *dest; + struct lyd_node *node; + FILE *fp; + + fp = open_file(FIREWALLD_SERVICES_DIR, name); + if (!fp) + return SR_ERR_SYS; + + desc = lydx_get_cattr(cfg, "description"); + dest = lydx_get_cattr(cfg, "destination"); + + fprintf(fp, "\n"); + + if (desc) + fprintf(fp, " %s\n", desc); + + 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"); + 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(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; + + 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), (*priority)++); + + if (desc) + fprintf(fp, " %s\n", desc); + + LYX_LIST_FOR_EACH(lyd_child(cfg), node, "ingress") + fprintf(fp, " \n", lyd_get_value(node)); + + LYX_LIST_FOR_EACH(lyd_child(cfg), node, "egress") + fprintf(fp, " \n", lyd_get_value(node)); + + 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 (*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, (*priority)++); + else + fprintf(fp, " \n", (*priority)++); + + 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"); + + fprintf(fp, "\n"); + + return close_file(fp); +} + +static int generate_firewalld_conf(struct lyd_node *cfg) +{ + FILE *fp; + + fp = fopen(FIREWALLD_CONF, "w"); + if (!fp) { + ERRNO("Failed creating %s", FIREWALLD_CONF); + return SR_ERR_SYS; + } + + fprintf(fp, "DefaultZone=%s\n", lydx_get_cattr(cfg, "default")); + fprintf(fp, "LogDenied=%s\n", lydx_get_cattr(cfg, "logging") ?: "off"); + + fprintf(fp, "FirewallBackend=nftables\n"); + fprintf(fp, "IndividualCalls=no\n"); + + /* + * Set nftables rule set to be owned exclusively by firewalld. + * This prevents other entities from mistakenly (or maliciously) + * modifying firewalld's rule set -- e.g., 'nft flush ruleset' + * will not affect the firewalld rules. + */ + fprintf(fp, "NftablesTableOwner=yes\n"); + + /* TODO: add config option to enable nftables flowtable (fastpath) */ + fprintf(fp, "NftablesFlowtable=off\n"); + + /* TODO: Add config option to enable this useful debug option. */ + fprintf(fp, "NftablesCounters=no\n"); + + /* Drop all traffic, except established connections, while rules are updated */ + fprintf(fp, "ReloadPolicy=INPUT:DROP,FORWARD:DROP,OUTPUT:DROP\n"); + fprintf(fp, "FlushAllOnReload=yes\n"); + + /* Seamless integration with podman -- published ports are opened. */ + fprintf(fp, "StrictForwardPorts=no\n"); + + /* Performs reverse path filtering (RPF) on IPv6 packets as per RFC 3704 */ + fprintf(fp, "IPv6_rpfilter=loose-forward\n"); + + /* + * Filter IPv6 traffic with 6to4 destination addresses that correspond + * to IPv4 addresses that should not be routed over the public internet. + */ + fprintf(fp, "RFC3964_IPv4=yes\n"); + + /* Remove all firewall rules on exit */ + fprintf(fp, "CleanupOnExit=yes\n"); + fclose(fp); + + return SR_ERR_OK; +} + +static int infer_zone(sr_session_ctx_t *session, const char *name, const char *desc, + const char *action, const char *services[]) +{ + int rc; + + DEBUG("Inferring zone %s (%s), action %s", name, desc, action); + + rc = srx_set_str(session, desc, 0, XPATH "/zone[name='%s']/description", name); + if (rc) + return rc; + + rc = srx_set_str(session, action, 0, XPATH "/zone[name='%s']/action", name); + if (rc) + return rc; + + for (int i = 0; services && services[i]; i++) { + rc = srx_set_str(session, services[i], 0, XPATH "/zone[name='%s']/service[.='%s']", + name, services[i]); + if (rc) + return rc; + } + + return SR_ERR_OK; +} + +#if INFER_POLICY +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; +} +#endif + +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 *tree, *global; + struct lyd_node *clist, *cnode; + struct lyd_node *diff = NULL; + sr_error_t err = SR_ERR_OK; + sr_data_t *cfg = NULL; + char **ifaces = NULL; + + switch (event) { + case SR_EV_CHANGE: + /* Generate configuration to /etc/firewalld+ */ + break; + + case SR_EV_ABORT: + systemf("rm -rf " FIREWALLD_DIR_NEXT); + return SR_ERR_OK; + + case SR_EV_DONE: + if (!fisdir(FIREWALLD_DIR_NEXT)) { + /* Firewall is disabled */ + systemf("initctl -nbq disable firewalld"); + return SR_ERR_OK; + } + + /* Firewall is enabled, roll in new configuration */ + systemf("rm -rf " FIREWALLD_DIR); + if (rename(FIREWALLD_DIR_NEXT, FIREWALLD_DIR)) { + ERRNO("Failed rolling in firewalld configuration"); + return SR_ERR_SYS; + } + + systemf("initctl -nbq touch firewalld"); + systemf("initctl -nbq enable firewalld"); + return SR_ERR_OK; + + default: + return SR_ERR_OK; + } + + 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); + + /* Clean up any stale /etc/firewalld+ first */ + systemf("rm -rf " FIREWALLD_DIR_NEXT); + + /* If firewall is disabled or not enabled, don't generate config */ + if (!global || !lydx_is_enabled(global, "enabled")) { + /* Firewall is disabled - no /etc/firewalld+ directory */ + goto done; + } + + /* Get L3 interfaces for default zone assignment */ + if (ietf_interfaces_get_all_l3(tree, &ifaces) != 0) { + ERROR("Failed to get L3 interfaces"); + ifaces = NULL; + } + + err = srx_get_diff(session, &diff); + if (err) + goto err_release_data; + + if (!diff) + goto err_release_data; + + /* Create /etc/firewalld+ directory structure */ + if (fmkpath(0755, FIREWALLD_DIR_NEXT) || + fmkpath(0755, FIREWALLD_ZONES_DIR) || + fmkpath(0755, FIREWALLD_SERVICES_DIR) || + fmkpath(0755, FIREWALLD_POLICIES_DIR)) { + ERROR("Failed creating " FIREWALLD_DIR_NEXT " directory structure"); + err = SR_ERR_SYS; + goto err_release_data; + } + + if (lydx_get_descendant(diff, "firewall", "default", NULL) || + lydx_get_descendant(diff, "firewall", "logging", NULL)) + generate_firewalld_conf(global); + + /* + * Regenerate everything if anything in firewall changed, firewalld + * handles the 'diff' for us. Starting priority for policies are at + * -14999 because at -15000 is the first "Allow host IPv6" immutable + * (default/built-in) policy from firewalld. We want the user rules + * to be between that and the default 'drop-all' implicit rule. + */ + if (lydx_get_descendant(diff, "firewall", NULL)) { + const char *default_zone = lydx_get_cattr(global, "default"); + struct lyd_node *list, *node; + int priority = -14999; + + /* 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) + 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(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(cnode, name, ifaces); + break; + } + + /* Regenerate all services */ + clist = lydx_get_descendant(tree, "firewall", "service", NULL); + LYX_LIST_FOR_EACH(clist, cnode, "service") + generate_service(cnode, lydx_get_cattr(cnode, "name")); + + /* Regenerate all policies with sequential priority allocation */ + clist = lydx_get_descendant(tree, "firewall", "policy", NULL); + LYX_LIST_FOR_EACH(clist, cnode, "policy") { + 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; + } + } + } + +done: + if (ifaces) { + for (int i = 0; ifaces[i]; i++) + free(ifaces[i]); + free(ifaces); + } + + if (diff) + lyd_free_tree(diff); +err_release_data: + if (cfg) + sr_release_data(cfg); + + return err; +} + +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 *svc[] = {"ssh", "dhcpv6-client", NULL}; +#if INFER_POLICY + 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} + }; +#endif + 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 == 0 || cnt) { + WARN("firewall has %zu zone(s) defined, but no default zone! (rc %d)", cnt, rc); + return 0; + } + + rc = infer_zone(session, "public", "Public, unknown network. Only SSH and DHCPv6 client allowed.", + "reject", svc); + if (rc) + return rc; + + /* Set up default zone for new networks */ + rc = srx_set_str(session, "public", 0, XPATH "/default"); + if (rc) + return rc; + +#if INFER_POLICY + /* 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; +#endif + 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: + ERROR("init failed: %s", sr_strerror(rc)); + return rc; +} diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc index f260c53a..298145df 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -28,6 +28,9 @@ 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-firewall-services@2025-04-26.yang" + "infix-firewall-icmp-types@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-icmp-types.yang b/src/confd/yang/confd/infix-firewall-icmp-types.yang new file mode 100644 index 00000000..def8238a --- /dev/null +++ b/src/confd/yang/confd/infix-firewall-icmp-types.yang @@ -0,0 +1,169 @@ +module infix-firewall-icmp-types { + yang-version 1.1; + namespace "urn:infix:firewall:icmp-types:ns:yang:1.0"; + prefix ifw-icmp; + + organization "KernelKit"; + contact "kernelkit@googlegroups.com"; + description "Common well-defined network services."; + + revision 2025-04-26 { + description "Initial revision."; + reference "internal"; + } + + /* firewall-cmd --get-icmptypes */ + typedef type { + description "Available ICMP/ICMPv6 types."; + + type enumeration { + 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 "General error sent by hosts or gateways when a destination cannot be reached."; + } + 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 mld-listener-done { + description "IPv6 multicast message sent when a host leaves a multicast group."; + } + enum mld-listener-query { + description "IPv6 multicast router query to discover group membership information."; + } + enum mld-listener-report { + description "IPv6 multicast message sent by hosts to report group membership."; + } + enum mld2-listener-report { + description "IPv6 multicast listener report message for MLDv2 enhanced reporting."; + } + 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 "General message instructing a host to use a different route for future packets."; + } + 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 "Message sent by routers to periodically announce their presence and network configuration."; + } + enum 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 "Error sent when a packet's time-to-live expires during transit or reassembly."; + } + enum timestamp-reply { + description "IPv4 response message containing timestamp information for network time synchronization."; + } + enum timestamp-request { + description "IPv4 message requesting timestamp information from the destination for time synchronization."; + } + enum tos-host-redirect { + description "IPv4 message instructing to redirect packets based on both the type of service and specific host."; + } + enum tos-host-unreachable { + description "IPv4 error sent when a host is unreachable for the specific type of service requested."; + } + enum tos-network-redirect { + description "IPv4 message instructing to redirect packets based on both the type of service and network."; + } + 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 unknown-header-type { + description "IPv6 error sent when an unrecognized Next Header type is encountered in the packet."; + } + enum unknown-option { + description "IPv6 error sent when an unrecognized or unsupported IPv6 option is encountered."; + } + } + } +} diff --git a/src/confd/yang/confd/infix-firewall-icmp-types@2025-04-26.yang b/src/confd/yang/confd/infix-firewall-icmp-types@2025-04-26.yang new file mode 120000 index 00000000..c3288c01 --- /dev/null +++ b/src/confd/yang/confd/infix-firewall-icmp-types@2025-04-26.yang @@ -0,0 +1 @@ +infix-firewall-icmp-types.yang \ No newline at end of file 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 00000000..1ca90290 --- /dev/null +++ b/src/confd/yang/confd/infix-firewall-services.yang @@ -0,0 +1,386 @@ +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 "Common well-defined network services."; + + revision 2025-04-26 { + description "Initial revision."; + reference "internal"; + } + + typedef well-known-service { + description "Well-known network services, with standard port assignments from IANA."; + type enumeration { + enum "amqp" { + description "5672/tcp — Advanced Message Queuing Protocol for message-oriented middleware"; + } + enum "amqps" { + description "5671/tcp — Secure Advanced Message Queuing Protocol over SSL"; + } + enum "apcupsd" { + description "3551/tcp — APC uninterruptible power supply daemon protocol"; + } + enum "audit" { + description "60/tcp — Linux audit subsystem for security event logging"; + } + enum "bacula" { + description "9101-9103/tcp — Open source network backup tool"; + } + enum "bacula-client" { + description "9102/tcp — Bacula client for backup operations"; + } + enum "bgp" { + description "179/tcp — Border Gateway Protocol for internet routing"; + } + enum "cockpit" { + description "9090/tcp — Web-based server administration interface"; + } + enum "dhcp" { + description "67-68/udp — Dynamic Host Configuration Protocol for network configuration"; + } + enum "dhcpv6" { + description "547/udp — Allow incoming DHCP for IPv6 requests from clients or relay agents."; + } + enum "dhcpv6-client" { + description "546/udp — Allow a DHCP for IPv6 client to obtain a lease."; + } + enum "dns" { + description "53/tcp+udp — Domain Name System for name resolution"; + } + enum "dns-over-quic" { + description "853/udp — DNS over QUIC protocol for encrypted domain name resolution"; + } + enum "dns-over-tls" { + description "853/tcp — DNS over TLS protocol for encrypted domain name resolution"; + } + enum "elasticsearch" { + description "9300/tcp — Distributed search and analytics engine"; + } + enum "ftp" { + description "20-21/tcp — File Transfer Protocol for file transfers"; + } + enum "git" { + description "9418/tcp — Git daemon for version control repository access"; + } + enum "grafana" { + description "3000/tcp — Analytics and monitoring dashboard platform"; + } + enum "gpsd" { + description "2947/tcp — GPS daemon for location services"; + } + enum "gre" { + description "47/ip — Generic Routing Encapsulation for tunneling"; + } + enum "http" { + description "80/tcp — Hypertext Transfer Protocol for web traffic"; + } + enum "https" { + description "443/tcp — Secure Hypertext Transfer Protocol for encrypted web traffic"; + } + enum "http3" { + description "443/udp — HTTP/3 protocol over QUIC for faster web traffic"; + } + enum "imap" { + description "143/tcp — Internet Message Access Protocol for email access"; + } + enum "imaps" { + description "993/tcp — Secure Internet Message Access Protocol for encrypted email access"; + } + enum "ipp" { + description "631/tcp+udp — Internet Printing Protocol (IPP) is used for distributed printing."; + } + enum "iperf2" { + description "5001/tcp — Network bandwidth measurement tool version 2"; + } + enum "iperf3" { + description "5201/tcp — Network bandwidth measurement tool version 3"; + } + enum "ipsec" { + description "500/udp — Internet Protocol Security for VPN connections"; + } + enum "irc" { + description "6667/tcp — Internet Relay Chat for text messaging"; + } + enum "ircs" { + description "6697/tcp — Secure Internet Relay Chat over SSL"; + } + enum "jenkins" { + description "8080/tcp — Open source automation server for CI/CD"; + } + enum "kerberos" { + description "88/tcp+udp — Network authentication protocol"; + } + enum "kadmin" { + description "749/tcp — Kerberos administration server"; + } + enum "kibana" { + description "5601/tcp — Data visualization dashboard for Elasticsearch"; + } + enum "klogin" { + description "543/tcp — Kerberos remote login"; + } + enum "kpasswd" { + description "464/tcp+udp — Kerberos password changing protocol"; + } + enum "kprop" { + description "754/tcp — Kerberos database propagation"; + } + enum "kshell" { + description "544/tcp — Kerberos remote shell"; + } + enum "ldap" { + description "389/tcp — Lightweight Directory Access Protocol for directory services"; + } + enum "ldaps" { + description "636/tcp — Lightweight Directory Access Protocol over SSL"; + } + enum "libvirt" { + description "16509/tcp — Virtualization management daemon"; + } + enum "libvirt-tls" { + description "16514/tcp — Secure virtualization management over TLS"; + } + enum "llmnr" { + description "5355/tcp+udp — Link-Local Multicast Name Resolution"; + } + enum "llmnr-client" { + description "5355/udp — LLMNR client for name resolution"; + } + enum "llmnr-tcp" { + description "5355/tcp — LLMNR over TCP"; + } + enum "llmnr-udp" { + description "5355/udp — LLMNR over UDP"; + } + enum "matrix" { + description "8008/tcp — Matrix chat protocol server"; + } + enum "mdns" { + description "5353/udp — Multicast DNS for local network service discovery"; + } + enum "mongodb" { + description "27017/tcp — Document-oriented NoSQL database"; + } + enum "minecraft" { + description "25565/tcp — Minecraft game server"; + } + enum "minidlna" { + description "8200/tcp — Lightweight DLNA/UPnP media server"; + } + enum "mndp" { + description "5678/udp — MikroTik Neighbor Discovery Protocol"; + } + enum "mosh" { + description "60000-61000/udp — Mobile shell for remote terminal access"; + } + enum "mpd" { + description "6600/tcp — Music Player Daemon"; + } + enum "mqtt" { + description "1883/tcp — Message Queuing Telemetry Transport for IoT"; + } + enum "mqtt-tls" { + description "8883/tcp — Secure MQTT over TLS for IoT"; + } + enum "mssql" { + description "1433/tcp — Microsoft SQL Server database"; + } + enum "mysql" { + description "3306/tcp — MySQL database server connections"; + } + enum "nbd" { + description "10809/tcp — Network Block Device"; + } + 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"; + } + enum "nfs3" { + description "2049/tcp+udp — Network File System version 3"; + } + enum "nmea-0183" { + description "10110/tcp — Marine electronics data interface"; + } + enum "nrpe" { + description "5666/tcp — Nagios Remote Plugin Executor"; + } + enum "ntp" { + description "123/udp — Network Time Protocol for time synchronization"; + } + enum "openvpn" { + description "1194/udp — OpenVPN secure tunnel for VPN connections"; + } + enum "opentelemetry" { + description "4317/tcp — Open source observability framework"; + } + enum "pop3" { + description "110/tcp — Post Office Protocol version 3 for email retrieval"; + } + enum "pop3s" { + description "995/tcp — Secure Post Office Protocol version 3 for encrypted email retrieval"; + } + enum "plex" { + description "32400/tcp — Plex media server"; + } + enum "postgresql" { + description "5432/tcp — PostgreSQL database server connections"; + } + enum "prometheus" { + description "9090/tcp — Monitoring system and time series database"; + } + enum "proxy-dhcp" { + description "4011/udp — Proxy DHCP for PXE boot"; + } + enum "ptp" { + description "319-320/udp — Precision Time Protocol"; + } + enum "puppetmaster" { + description "8140/tcp — Puppet configuration management server"; + } + enum "radius" { + description "1812-1813/tcp+udp — Remote Authentication Dial-in User Service"; + } + enum "quassel" { + description "4242/tcp — Quassel IRC client-server protocol"; + } + enum "radsec" { + description "2083/tcp — RADIUS over TLS"; + } + enum "rdp" { + description "3389/tcp — Remote Desktop Protocol for Windows remote access"; + } + enum "redis" { + description "6379/tcp — In-memory data structure store for database and cache"; + } + enum "redis-sentinel" { + description "26379/tcp — Redis Sentinel for high availability"; + } + enum "restconf" { + description "443/tcp — RESTful Network Configuration Protocol for HTTP-based network management"; + } + enum "rsyncd" { + description "873/tcp — Rsync daemon for centralized file synchronization"; + } + enum "rtsp" { + description "554/tcp — Real Time Streaming Protocol"; + } + enum "samba" { + description "445/tcp — Windows file and printer sharing"; + } + enum "samba-client" { + description "138/udp — Windows file and printer sharing (client-only)"; + } + enum "samba-dc" { + description "389/tcp — Samba Active Directory Domain Controller"; + } + enum "sane" { + description "6566/tcp — Scanner Access Now Easy network scanning"; + } + enum "sip" { + description "5060/tcp+udp — Session Initiation Protocol for VoIP communications"; + } + enum "sips" { + description "5061/tcp+udp — Secure Session Initiation Protocol for encrypted VoIP"; + } + enum "smtp" { + description "25/tcp — Simple Mail Transfer Protocol for email transmission"; + } + enum "smtps" { + description "465/tcp — Secure Simple Mail Transfer Protocol over SSL"; + } + enum "slp" { + description "427/tcp+udp — Service Location Protocol"; + } + enum "snmp" { + description "161/udp — Simple Network Management Protocol for network monitoring"; + } + enum "snmptrap" { + description "162/udp — Simple Network Management Protocol trap notifications"; + } + enum "snmptls" { + description "10161/tcp — SNMP over TLS"; + } + enum "snmptls-trap" { + description "10162/tcp — SNMP trap over TLS"; + } + enum "spotify-sync" { + description "57621/tcp — Spotify Connect synchronization"; + } + enum "ssh" { + description "22/tcp — Secure Shell for remote login and command execution"; + } + enum "stun" { + description "3478/tcp+udp — Session Traversal Utilities for NAT"; + } + enum "stuns" { + description "5349/tcp — Secure STUN over TLS"; + } + enum "supertuxkart" { + description "7321/tcp — SuperTuxKart racing game server"; + } + enum "svn" { + description "3690/tcp — Subversion version control system"; + } + enum "syslog" { + description "514/udp — System logging protocol for log message transmission"; + } + enum "syslog-tls" { + description "6514/tcp — Secure system logging protocol over TLS"; + } + enum "syncthing" { + description "22000/tcp — Continuous file synchronization"; + } + enum "syncthing-gui" { + description "8384/tcp — Syncthing web interface"; + } + enum "syncthing-relay" { + description "22067/tcp — Syncthing relay protocol"; + } + enum "synergy" { + description "24800/tcp — Keyboard and mouse sharing"; + } + enum "ssdp" { + description "1900/udp — Simple Service Discovery Protocol for UPnP device discovery"; + } + enum "telnet" { + description "23/tcp — Telnet protocol for remote terminal access"; + } + enum "tftp" { + description "69/udp — Trivial File Transfer Protocol for simple file transfers"; + } + enum "turn" { + description "3478/tcp+udp — Traversal Using Relay NAT for firewall traversal"; + } + enum "turns" { + description "5349/tcp — Secure TURN over TLS"; + } + enum "vnc-server" { + description "5900-5906/tcp — Virtual Network Computing server for remote desktop access"; + } + enum "vrrp" { + description "112/ip — Virtual Router Redundancy Protocol"; + } + enum "warpinator" { + description "42000/tcp — File sharing tool by Linux Mint"; + } + enum "wireguard" { + description "51820/udp — Modern VPN tunnel for secure networking"; + } + enum "xdmcp" { + description "177/tcp+udp — X Display Manager Control Protocol for remote X11 sessions"; + } + enum "zerotier" { + description "9993/udp — ZeroTier virtual network service"; + } + } + } +} 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 00000000..12d91ca3 --- /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 new file mode 100644 index 00000000..92a9c7f5 --- /dev/null +++ b/src/confd/yang/confd/infix-firewall.yang @@ -0,0 +1,495 @@ +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"; + } + + import infix-firewall-icmp-types { + prefix ifw-icmp; + reference "internal"; + } + + import infix-firewall-services { + prefix ifw-svc; + reference "internal"; + } + + 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 ident { + description "Generic filesystem-safe identifier (filename)."; + type string { + length "2..64"; + pattern '[a-zA-Z0-9\-_]+'; + } + } + + typedef zone-action { + description "Default action for a zone."; + + type enumeration { + enum accept { + description "Accept all connections by default."; + } + enum reject { + description "Reject all connections, except ICMP, 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 enumeration { + enum HOST { + description "Refers to the local host/device itself"; + } + enum ANY { + description "Refers to any zone"; + } + } + type leafref { + path "../../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."; + } + } + } + + /* + * Main container and configuration + */ + + container firewall { + description "Zone-based firewall configuration."; + presence "Activate firewall."; + + leaf enabled { + 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 "../zone/name"; + } + mandatory true; + } + + leaf logging { + description "Enable logging of denied (rejected/dropped) packets. + + Add logging rules right before reject and drop rules in the INPUT, FORWARD and + OUTPUT chains for the default rules and also final reject and drop rules in + zones for the configured link-layer packet type."; + 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; + } + + list zone { + description "A zone defines a level of trust for network connections."; + key "name"; + + must "count(interface) = 0 or count(network) = 0" { + error-message "A zone cannot have both interfaces and networks - use interfaces for local traffic or networks for forwarding"; + } + + leaf name { + description "Name of the zone."; + type ident; + } + + leaf action { + description "Default action for traffic from this zone to HOST. + + When 'accept', all traffic from this zone can reach HOST services. + When 'reject' or 'drop', only explicitly listed services are allowed + to reach HOST, all other traffic is rejected/dropped. I.e., an ICMP + unreachable message or silent drop."; + type zone-action; + default reject; + } + + 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; + } + + 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 network { + description "IP networks assigned to this zone."; + type inet:ip-prefix; + } + + leaf-list service { + description "Services allowed from this zone to HOST (INPUT chain only). + + These define exceptions when action is 'reject' or 'drop'. + Ignored when action is 'accept' (all services allowed)."; + type union { + type leafref { + path "../../service/name"; + } + type ifw-svc:well-known-service; + } + } + + list port-forward { + description "Forward traffic to another port and/or host (DNAT). + + Port forwarding rules within a zone apply to traffic matching + the zone's interfaces or networks."; + key "lower proto"; + + leaf lower { + description "Local port to forward from."; + type inet:port-number; + mandatory true; + } + + leaf upper { + description "Upper port when forwarding a range of ports."; + type inet:port-number; + must "../lower <= ."; + } + + leaf proto { + description "Network protocol to forward."; + type protocol-type; + mandatory true; + } + + container to { + description "Destination to forward to."; + + leaf addr { + description "Destination IPv4/IPv6 address to forward to."; + type inet:ip-address; + } + + leaf port { + description "Destination port to forward to. Defaults to 'lower', + and the upper is then automatically calculated."; + type inet:port-number; + } + } + } + } + + list policy { + description "Rules for filtering traffic forwarded between zones (inter-zone)."; + ordered-by user; + key "name"; + + must "count(ingress) > 0 and count(egress) > 0" { + error-message "A policy must have at least one ingress and one egress zone defined"; + } + + leaf name { + description "Unique identifier (filename) for this policy, e.g., LAN-to-WAN."; + type ident; + } + + leaf action { + description "Action for non-matching traffic. + + With 'continue' non-matching traffic is allowed to continue to + be processed by the next policy rule, or in the end be dropped."; + type policy-action; + default "reject"; + } + + 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; + } + + 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. + + Matching traffic will have their source IP address changed on egress, + using the IP address of the interface the traffic egresses."; + type boolean; + } + + leaf-list service { + description "Services to allow between ingress and egress zones. + + Services that are not in this list follow the default policy action. When + it is 'accept', all services are accepted, regardless of this list. When + action is 'reject' or 'drop': these services are accepted, while others are + rejected/dropped. When action is 'continue', traffic not matching the list + is passed on to the next policy."; + type union { + type leafref { + path "../../service/name"; + } + 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"; + + must "action" { + error-message "Custom filters must have a valid action."; + } + + must "not(icmp) or icmp/type" { + error-message "ICMP filters must specify an ICMP type."; + } + + 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 ifw-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; + } + } + } + + leaf priority { + // Sorting order as read from firewalld + description "Effective priority of this filter."; + config false; + type int16; + } + } + + 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 ident; + } + + 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 { + description "Lower port in range."; + type inet:port-number; + } + + leaf upper { + description "Upper port in range."; + type inet:port-number; + must "../lower <= ."; + } + + leaf proto { + description "Layer 4 protocol."; + type protocol-type; + } + } + + leaf destination { + type union { + type inet:ip-address; + type inet:ip-prefix; + } + description "Destination IP address/group to match this service to."; + } + } + + leaf lockdown { + description "Current state of emergency lockdown mode."; + config false; + type boolean; + } + + 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; + } + } + } + } +} 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 00000000..6e6fada4 --- /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 diff --git a/src/klish-plugin-infix/src/infix.c b/src/klish-plugin-infix/src/infix.c index 54bae655..41c17df5 100644 --- a/src/klish-plugin-infix/src/infix.c +++ b/src/klish-plugin-infix/src/infix.c @@ -142,6 +142,61 @@ int infix_ifaces(kcontext_t *ctx) return 0; } +static int firewall_dbus_completion(const char *interface, const char *method, const char *parser) +{ + 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_dbus_completion("zone", "getActiveZones", + "sed 's/^(//; s/,)$//' | sed 's/@as \\[\\]/[]/g' | 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_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 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) { kpargv_t *pargv = kcontext_pargv(ctx); @@ -228,6 +283,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 e12f9e75..c40e249d 100644 --- a/src/klish-plugin-infix/xml/infix.xml +++ b/src/klish-plugin-infix/xml/infix.xml @@ -113,6 +113,27 @@ + + + + + + + + + + + + + + + + + + + + + @@ -490,6 +511,43 @@ jq -C . /cfg/startup-config.cfg |pager + + + + + + doas -u $USER cat /log/firewall.log |pager +G + + + + + + + + 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 + + @@ -499,6 +557,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 232818ce..f81421ef 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -3,10 +3,100 @@ import json import argparse import sys import re +import textwrap +import ipaddress +from collections import deque from datetime import datetime, timezone UNIT_TEST = False + +def compress_interface_list(interfaces): + """Converts interface list to compact range notation. + + Args: + interfaces: List of interface names + + Returns: + str: Compressed representation using ranges + + Algorithm: + 1. Extract prefix+number pairs via regex + 2. Group by prefix, sort numerically + 3. Find consecutive sequences + 4. Format as ranges (e1-e4) or singles (e1) + 5. Combine with non-numeric interfaces + + Examples: + ['e1', 'e2', 'e3', 'e4'] -> 'e1-e4' + ['e1', 'e2', 'e4', 'e5'] -> 'e1-e2, e4-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 @@ -96,6 +186,43 @@ class PadLldp: port_id = 20 +class PadFirewall: + zone_locked = 2 + zone_name = 21 + zone_type = 6 + zone_data = 34 + zone_services = 38 + + zone_flow_to = 20 + zone_flow_action = 14 + zone_flow_policy = 20 + zone_flow_services = 45 + + policy_locked = 2 + policy_name = 21 + policy_action = 9 + policy_ingress = 33 + policy_egress = 35 + + service_name = 20 + service_ports = 69 + + # Firewall log display formatting + log_time = 15 # ISO format: MM-DD HH:MM:SS + log_action = 6 # REJECT/DROP + small buffer + log_iif = 11 # Input interface + small buffer + log_src = 26 # IPv6 addresses (shortened) or IPv4 + log_dst = 26 # IPv6 addresses (shortened) or IPv4 + log_proto = 5 # 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_locked + cls.zone_name + cls.zone_type + cls.zone_data \ + + cls.zone_services + + class Decore(): @staticmethod def decorate(sgr, txt, restore="0"): @@ -125,6 +252,14 @@ class Decore(): 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") @@ -133,6 +268,34 @@ class Decore(): 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 + 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 +334,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 +833,15 @@ class Iface: 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 +1509,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 +1596,861 @@ 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"{'IIF':>{PadFirewall.log_iif}} " + f"{'SOURCE':<{PadFirewall.log_src}} " + f"{'DEST':<{PadFirewall.log_dst}} " + f"{'PROTO':<{PadFirewall.log_proto}} " + f"{'PORT':>{PadFirewall.log_port}}") + + Decore.title(f"Log (last {limit})", 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['in_iface']:>{PadFirewall.log_iif}} " + 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): + """Main firewall display orchestrator. + + Args: + json: Complete firewall configuration dict + + Displays: + - Status with lockdown/logging alerts + - Zone-to-zone traffic matrix + - Zone and policy tables + - Firewall logs (if logging enabled) + """ + fw = json.get('infix-firewall:firewall', {}) + if not fw: + print("Firewall disabled.") + return + + # 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')}") + + show_firewall_matrix(fw) + 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 build_policy_map(policies): + """Creates optimized (ingress,egress) -> policy_info lookup map. + + Args: + policies: List of policy dicts + + Returns: + dict: {(from_zone, to_zone): { + 'allow': bool, # Full access (action=accept, no restrictions) + 'conditional': bool, # Limited access (services/port-forwards) + 'services': set, # Allowed service names + 'policies': list # Contributing policy names + }} + + Logic: + - action=accept + no services/port-forwards → allow=True + - action=accept + services/port-forwards → conditional=True + - action=reject + services → conditional=True + - action=reject + no services → allow=False, conditional=False + + Excludes: Global ANY-to-ANY policies (handled separately) + """ + policy_map = {} + + for policy in policies: + ingress_zones = policy.get('ingress', []) + egress_zones = policy.get('egress', []) + services = policy.get('service', []) + action = policy.get('action', 'reject') + policy_name = policy.get('name', 'unknown') + + for ing in ingress_zones: + for egr in egress_zones: + # Handle specific zone-to-zone flows (excluding global ANY-to-ANY) + if not (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 action in ['accept', 'continue']: + if services: + # Accept policy with specific services - conditional only + policy_map[key]['conditional'] = True + policy_map[key]['services'].update(services) + # Don't set allow=True - this is conditional, not full allow + else: + # Accept policy with no restrictions - allow all + policy_map[key]['allow'] = True + else: + # Reject/drop policies + if services: + # Reject with service exceptions - conditional access only + policy_map[key]['conditional'] = True + policy_map[key]['services'].update(services) + # Don't set allow=True - this is conditional, not full allow + # else: reject with no services - stays deny (allow=False) + + policy_map[key]['policies'].append(policy_name) + return policy_map + + +def format_port_forwards(port_forwards, max_length=35): + """Format port-forward rules for compact display. + + Args: + port_forwards: List of port-forward dicts + max_length: Maximum string length before truncation + + Returns: + str: Formatted port-forward string like ":80/tcp→192.168.1.10:8080, :443/tcp→..." + """ + if not port_forwards: + return "" + + formatted_rules = [] + for pf in port_forwards: + lower = pf.get('lower') + upper = pf.get('upper') + proto = pf.get('proto', 'tcp') + to_addr = pf.get('to', {}).get('addr') + to_port = pf.get('to', {}).get('port', lower) + + if lower: + # Format port range or single port + if upper and upper != lower: + port_spec = f":{lower}-{upper}/{proto}" + else: + port_spec = f":{lower}/{proto}" + + # Format destination + if to_addr: + if to_port: + dest = f"{to_addr}:{to_port}" + else: + dest = to_addr + formatted_rules.append(f"{port_spec} → {dest}") + + result = ", ".join(formatted_rules) + if len(result) > max_length: + # Truncate and add indicator + truncated = result[:max_length-4] + "..." + return truncated + return result + + +def find_zone_by_ip(ip_str, zones): + """Find which zone contains the given IP address. + + Args: + ip_str: IP address string to lookup + zones: List of zone configurations + + Returns: + str: Zone name if found, None otherwise + """ + try: + target_ip = ipaddress.ip_address(ip_str) + for zone in zones: + networks = zone.get('network', []) + for network_str in networks: + try: + network = ipaddress.ip_network(network_str, strict=False) + if target_ip in network: + return zone['name'] + except ValueError: + continue + except ValueError: + pass + return None + + +def traffic_flow(from_zone, to_zone, policy_map, zones, policies, cell_width): + """Core zone-to-zone traffic analysis with colored cell output. + + Args: + from_zone, to_zone: Zone names for traffic direction + policy_map: Pre-computed policy lookup from build_policy_map() + zones: Zone configuration list + policies: Not used (kept for compatibility) + cell_width: Matrix cell width for formatting + + Returns: + str: Colored terminal cell with symbol (✓/✗/⚠/—) + + Logic: + - HOST↔HOST: Gray — (not applicable) + - HOST→zone: Green ✓ (firewall can reach zones) + - zone→HOST: Based on zone input config (action/services) + - zone→zone: Based on explicit policy only + - Intra-zone: Requires explicit policy + - No policy: Red ✗ (default deny) + + Symbols: ✓=allow all, ✗=deny, ⚠=conditional, —=n/a + """ + 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: controlled by zone input configuration + if to_zone == "HOST": + # Find the zone configuration for this source zone + zone_config = None + for zone in zones: + if zone.get('name') == from_zone: + zone_config = zone + break + + if zone_config: + action = zone_config.get('action', 'reject') + services = zone_config.get('service', []) + port_forwards = zone_config.get('port-forward', []) + + if action == 'accept': + # Zone allows all traffic to HOST + return make_cell("✓", Decore.green_bg) + elif services or port_forwards: + # Zone has service exceptions or port forwards (conditional access) + return make_cell("⚠", Decore.yellow_bg) + else: + # Zone blocks all traffic to HOST + return make_cell("✗", Decore.red_bg) + + # Zone not found - default deny + return make_cell("✗", Decore.red_bg) + + # Intra-zone communication now requires explicit policies + if from_zone == to_zone: + # Look for explicit intra-zone policy (e.g., "lan-to-lan") + key = (from_zone, to_zone) + policy = policy_map.get(key) + if policy and policy['allow']: + return make_cell("✓", Decore.green_bg) + return make_cell("✗", Decore.red_bg) + + # Check for explicit policy between these zones + key = (from_zone, to_zone) + policy = policy_map.get(key) + + if policy: + if policy['allow']: + # Policy explicitly allows all traffic + return make_cell("✓", Decore.green_bg) + elif policy['conditional']: + # Policy has service restrictions or port forwards + return make_cell("⚠", Decore.yellow_bg) + else: + # Policy explicitly denies + return make_cell("✗", Decore.red_bg) + + # Check for port-forwards from source zone to target zone via IP mapping + source_zone_config = None + for zone in zones: + if zone.get('name') == from_zone: + source_zone_config = zone + break + + if source_zone_config: + port_forwards = source_zone_config.get('port-forward', []) + for pf in port_forwards: + to_addr = pf.get('to', {}).get('addr') + if to_addr: + target_zone = find_zone_by_ip(to_addr, zones) + if target_zone == to_zone: + # Port forward creates conditional access to target zone + return make_cell("⚠", Decore.yellow_bg) + + # No explicit policy - default deny + return make_cell("✗", Decore.red_bg) + + +def show_firewall_matrix(fw): + """Renders visual zone-to-zone traffic flow matrix. + + Args: + fw: Firewall config dict with zones/policies + + Algorithm: + 1. Collect zones with interfaces/networks + implicit HOST + 2. Build policy lookup map via build_policy_map() + 3. Generate matrix cells using traffic_flow() logic + 4. Render with box-drawing chars and colored symbols + + Symbols: ✓=allow, ✗=deny, ⚠=conditional, —=n/a + """ + zones = fw.get('zone', []) + policies = fw.get('policy', []) + + # Build zone list - include zones with interfaces OR networks + zone_names = [] + for z in zones: + interfaces = z.get('interface', []) + networks = z.get('network', []) + # Include if zone has interfaces OR networks (non-empty lists) + if interfaces or networks: + zone_names.append(z['name']) + + # Always add the implicit HOST zone + zone_names.insert(0, "HOST") + + 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: + # Find symbol for this cell based on traffic flow + symbol = traffic_flow(from_zone, to_zone, policy_map, + zones, policies, col_width) + row += f"{symbol}│" + print(f"{indent}{row}") + print(f"{indent}{bottom_border}") + + # 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): + """Displays zone configuration table or detailed zone analysis. + + Args: + json: Complete firewall config + zone_name: Optional specific zone name for detail view + + Table mode (zone_name=None): + - Zone name, type (ifaces/networks), members, HOST services + - Uses compress_interface_list() for clean display + - Shows immutable lock indicators + + Detail mode (zone_name specified): + - Complete zone configuration + - Traffic flow analysis to all other zones + - Policy-based access determination + """ + 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 = "" + networks = zone.get('network', []) + if not networks: + networks = "" + services = zone.get('service', []) + action = zone.get('action', 'reject') + + if action == 'accept': + services_display = "ANY" + elif services: + services_display = ", ".join(services) + else: + services_display = "(none)" + + print(format_description('description', description)) + print(f"{'name':<20}: {zone_name}") + print(f"{'action':<20}: {action}") + print(f"{'interface':<20}: {compress_interface_list(interfaces)}") + print(f"{'networks':<20}: {', '.join(networks)}") + print(f"{'services (to HOST)':<20}: {services_display}") + + # Show port forwards if any + port_forwards = zone.get('port-forward', []) + if port_forwards: + print(f"{'port forwards':<20}: {len(port_forwards)} rule(s)") + for fwd in port_forwards: + 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_info = fwd.get('to', {}) + to_addr = to_info.get('addr', '') + to_port = to_info.get('port', lower) # Default to source port + + if upper and to_port: + # Calculate upper destination port for ranges + port_diff = upper - lower + to_upper = to_port + port_diff + to_port_str = f"{to_port}-{to_upper}" + else: + to_port_str = str(to_port) + + to_display = f"{to_addr}:{to_port_str}" + print(f"{' - ' + from_port:<18} → {to_display}") + + hdr = (f"{'TO ZONE':<{PadFirewall.zone_flow_to}}" + f"{'ACTION':<{PadFirewall.zone_flow_action}}" + f"{'POLICY':<{PadFirewall.zone_flow_policy}}" + f"{'SERVICES':<{PadFirewall.zone_flow_services}}") + Decore.title(f"Traffic Flows: {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 + + # Check if there's a policy allowing this flow + 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') + 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:<{PadFirewall.zone_flow_policy}}" + f"{services_display}") + else: + hdr = (f"{'':<{PadFirewall.zone_locked}}" + f"{'NAME':<{PadFirewall.zone_name}}" + f"{'TYPE':<{PadFirewall.zone_type}}" + f"{'DATA':<{PadFirewall.zone_data}}" + f"{'ALLOWED HOST SERVICES':<{PadFirewall.zone_services}}") + Decore.title("Zones", len(hdr)) + print(Decore.invert(hdr)) + + for zone in zones: + name = zone.get('name', '') + action = zone.get('action', 'reject') + interface_list = zone.get('interface', []) + network_list = zone.get('network', []) + port_forwards = zone.get('port-forward', []) + services = zone.get('service', []) + + if action == 'accept': + services_display = "ANY" + elif services: + services_display = ", ".join(services) + else: + services_display = "(none)" + + immutable = zone.get('immutable', False) + locked = "⚷" if immutable else " " + + # Build configuration strings + config_lines = [] + + # Interfaces + if interface_list: + interfaces = compress_interface_list(interface_list) + config_lines.append(("iif", interfaces)) + else: + config_lines.append(("iif", "(none)")) + + # Networks + if network_list: + networks = ", ".join(network_list) + config_lines.append(("net", networks)) + + # Port forwards + if port_forwards: + pf_display = format_port_forwards(port_forwards) + config_lines.append(("fwd", pf_display)) + + # Print first line with zone name and services + if config_lines: + first_type, first_data = config_lines[0] + print(f"{locked:<{PadFirewall.zone_locked}}" + f"{name:<{PadFirewall.zone_name}}" + f"{first_type:<{PadFirewall.zone_type}}" + f"{first_data:<{PadFirewall.zone_data}}" + f"{services_display}") + + # Print additional configuration lines + for config_type, config_data in config_lines[1:]: + print(f"{'':<{PadFirewall.zone_locked}}" + f"{'':<{PadFirewall.zone_name}}" + f"{config_type:<{PadFirewall.zone_type}}" + f"{config_data}") + + # if zone != zones[-1]: # Don't add line after last zone + # print() + + +def show_firewall_policy(json, policy_name=None): + """Displays policy configuration table or detailed policy analysis. + + Args: + json: Complete firewall config + policy_name: Optional specific policy name for detail view + + Table mode (policy_name=None): + - Policy name, action, ingress/egress zones + - Sorted by priority (lower = higher precedence) + - Shows immutable lock indicators + + Detail mode (policy_name specified): + - Complete policy configuration + - Custom ICMP filters with type/family details + - Port forwarding rules with range mapping + - Service restrictions and masquerade settings + """ + 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', []) + action = policy.get('action', 'reject') + 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: + 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"{'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)") + + 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') + + 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}}" + f"{'ACTION':<{PadFirewall.policy_action}}" + f"{'INGRESS':<{PadFirewall.policy_ingress}}" + f"{'EGRESS':<{PadFirewall.policy_egress}}") + Decore.title("Policies", len(hdr)) + print(Decore.invert(hdr)) + + 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', [])) + 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 " " + + 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}}") + + +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 +2483,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 +2514,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/__main__.py b/src/statd/python/yanger/__main__.py index bf69178f..172f9de1 100644 --- a/src/statd/python/yanger/__main__.py +++ b/src/statd/python/yanger/__main__.py @@ -78,6 +78,9 @@ def main(): 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 00000000..a49f807e --- /dev/null +++ b/src/statd/python/yanger/infix_firewall.py @@ -0,0 +1,362 @@ +#!/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 +import re +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, dbus_interface=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): + """ + $ 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) + target = settings.get('target', 'default') + action = { + "%%REJECT%%": "reject", + "REJECT": "reject", + "ACCEPT": "accept", + "DROP": "drop", + "default": "accept" + } + + short = settings.get('short', '') + immutable = False + if short and "(immutable)" in short: + # Remove (immutable), added by us to set ⚷ symbol in output + short = short.replace("(immutable)", "").strip() + immutable = True + elif not short: + short = "" + + zone = { + "name": name, + "short": short, + "immutable": immutable, + "description": settings.get('description', 0), + "interface": list(settings.get('interfaces', [])), + "network": list(settings.get('sources', [])), + "action": action.get(target, "accept"), + "service": list(settings.get('services', [])) + } + + # Handle port forwarding from zone + port_forwards = [] + forwards = settings.get('forward_ports', []) + for fwd in forwards: + try: + if len(fwd) >= 4: + port, protocol, toport, toaddr = fwd[:4] # Fixed field order! + + # 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 - 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 lower + fwd_data['to']['port'] = fwd_data['lower'] + else: + # No destination port specified, use same as source lower + fwd_data['to']['port'] = fwd_data['lower'] + + port_forwards.append(fwd_data) + + except (ValueError, IndexError, TypeError) as e: + common.LOG.warning("Invalid port forward rule in zone %s: %s", name, e) + continue + + if port_forwards: + zone["port-forward"] = port_forwards + + 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): + """Get only active zones (loaded in kernel) instead of all zones""" + zones = [] + try: + fwz = get_interface("org.fedoraproject.FirewallD1.zone") + if not fwz: + return zones + + active_zones = fwz.getActiveZones() + for name, zone_info in active_zones.items(): + zone_data = get_zone_data(fwz, name) + if zone_data: + zone_data['interface'] = list(zone_info.get('interfaces', [])) + zone_data['network'] = list(zone_info.get('sources', [])) + 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, + "action": "reject", + "priority": 32767, + "ingress": [], + "egress": [] + } + + target = settings.get('target', 'CONTINUE') + action = { + "CONTINUE": "continue", + "ACCEPT": "accept", + "REJECT": "reject", + "DROP": "drop" + } + policy["action"] = 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 + + short = settings.get('short', '') + policy["immutable"] = bool(short and "(immutable)" in short) + + ingress = settings.get('ingress_zones', []) + if ingress: + policy["ingress"] = list(ingress) + + egress = settings.get('egress_zones', []) + if egress: + policy["egress"] = list(egress) + + services = settings.get('services', []) + if services: + policy["service"] = list(services) + + 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 + 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) + 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}", + "priority": prio, + "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: + common.LOG.warning("Failed querying policy %s via D-Bus: %s", name, e) + return None + + +def get_policies(fw): + policies = [] + try: + fwp = get_interface("org.fedoraproject.FirewallD1.policy") + if not fwp: + return policies + + for name in fwp.getPolicies(): + data = get_policy_data(fwp, name) + if data: + policies.append(data) + + except Exception as e: + common.LOG.warning("Failed querying policies: %s", e) + + # Add implicit drop/reject policy as the last rule + implicit_policy = { + "name": "default-drop", + "description": "Default deny rule - drops all unmatched traffic", + "action": "drop", + "priority": 32767, # Highest priority number (lowest precedence) + "ingress": ["ANY"], + "egress": ["ANY"], + "immutable": True + } + policies.append(implicit_policy) + + 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(), + "lockdown": bool(fw.queryPanicMode()) + } + } + + 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 f69894b4..dcd8738c 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; diff --git a/test/case/statd/system/cli/show-hardware b/test/case/statd/system/cli/show-hardware index a598d096..10645d55 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