diff --git a/board/common/rootfs/etc/bash.bashrc b/board/common/rootfs/etc/bash.bashrc index 34bbacf8c..a8835e833 100644 --- a/board/common/rootfs/etc/bash.bashrc +++ b/board/common/rootfs/etc/bash.bashrc @@ -19,14 +19,14 @@ log() { local fn="/var/log/syslog" [ -n "$1" ] && fn="/var/log/$1" - less +G "$fn" + less +G -r "$fn" } follow() { local fn="/var/log/syslog" [ -n "$1" ] && fn="/var/log/$1" - tail -F "$fn" + less +F -r "$fn" } _logfile_completions() diff --git a/board/common/rootfs/etc/finit.d/available/firewalld.conf b/board/common/rootfs/etc/finit.d/available/firewalld.conf new file mode 100644 index 000000000..a39a8d13a --- /dev/null +++ b/board/common/rootfs/etc/finit.d/available/firewalld.conf @@ -0,0 +1,3 @@ +service [2345] reload:'firewall 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 000000000..d3ab25c56 --- /dev/null +++ b/board/common/rootfs/etc/syslog.d/firewall.conf @@ -0,0 +1,6 @@ +# Log firewall denied/rejected packet logs to dedicated file +# https://www.cyberciti.biz/faq/enable-firewalld-logging-for-denied-packets-on-linux/ +:msg, contains, "_DROP" +kern.* -/var/log/firewall.log +:msg, contains, "_REJECT" +kern.* -/var/log/firewall.log diff --git a/board/common/rootfs/usr/bin/pager b/board/common/rootfs/usr/bin/pager index cea4c3ecb..ac5cd8ed3 100755 --- a/board/common/rootfs/usr/bin/pager +++ b/board/common/rootfs/usr/bin/pager @@ -5,10 +5,11 @@ # -K :: exit immediately when an interrupt character (usually ^C) is typed # -R :: Almost raw control charachters, only ANSI color escape sequences and # OSC 8 hyperlink sequences are output. Allows veritcal scrolling +# -r :: Causes "raw" control characters to be displayed, including unicode. # -X :: No termcap initialization and deinitialization set to the terminal. # This is what leaves the contents of the output on screen. export LESS="-P %f (press h for help or q to quit)" export LANG=en_US.UTF-8 -less -RIKd -FX "$@" +less -rIKd -FX "$@" diff --git a/board/common/rootfs/usr/bin/yorn b/board/common/rootfs/usr/bin/yorn index 60b1b37a7..cd4805e65 100755 --- a/board/common/rootfs/usr/bin/yorn +++ b/board/common/rootfs/usr/bin/yorn @@ -1,11 +1,18 @@ #!/bin/sh +opts="-n1" + +if [ "$1" = "-q" ]; then + opts="$opts -s" + shift +fi + Q=$@ /bin/echo -n "$Q, are you sure (y/N)? " -read -n1 yorn +read $opts yorn echo -if [ x$yorn != "xy" ] && [ x$yorn != "xY" ]; then +if [ "x$yorn" != "xy" ] && [ "x$yorn" != "xY" ]; then echo "OK, aborting." exit 1 fi diff --git a/configs/aarch64_defconfig b/configs/aarch64_defconfig index db394c516..6cfe97d02 100644 --- a/configs/aarch64_defconfig +++ b/configs/aarch64_defconfig @@ -71,6 +71,7 @@ BR2_PACKAGE_CONNTRACK_TOOLS=y BR2_PACKAGE_DNSMASQ=y BR2_PACKAGE_ETHTOOL=y BR2_PACKAGE_FPING=y +BR2_PACKAGE_FIREWALLD=y BR2_PACKAGE_FRR=y # BR2_PACKAGE_IFUPDOWN_SCRIPTS is not set BR2_PACKAGE_IPROUTE2=y diff --git a/configs/aarch64_minimal_defconfig b/configs/aarch64_minimal_defconfig index ecc5f053a..39e432df0 100644 --- a/configs/aarch64_minimal_defconfig +++ b/configs/aarch64_minimal_defconfig @@ -67,6 +67,7 @@ BR2_PACKAGE_AVAHI_DEFAULT_SERVICES=y BR2_PACKAGE_CHRONY=y BR2_PACKAGE_DNSMASQ=y BR2_PACKAGE_ETHTOOL=y +BR2_PACKAGE_FIREWALLD=y BR2_PACKAGE_FRR=y # BR2_PACKAGE_IFUPDOWN_SCRIPTS is not set BR2_PACKAGE_IPROUTE2=y diff --git a/configs/r2s_defconfig b/configs/r2s_defconfig index 6067d7769..8d411c8a9 100644 --- a/configs/r2s_defconfig +++ b/configs/r2s_defconfig @@ -89,6 +89,7 @@ BR2_PACKAGE_CONNTRACK_TOOLS=y BR2_PACKAGE_DNSMASQ=y BR2_PACKAGE_ETHTOOL=y BR2_PACKAGE_FPING=y +BR2_PACKAGE_FIREWALLD=y BR2_PACKAGE_FRR=y # BR2_PACKAGE_IFUPDOWN_SCRIPTS is not set BR2_PACKAGE_IPROUTE2=y diff --git a/configs/riscv64_defconfig b/configs/riscv64_defconfig index 8aca79ed1..6abe45ca5 100644 --- a/configs/riscv64_defconfig +++ b/configs/riscv64_defconfig @@ -83,6 +83,7 @@ BR2_PACKAGE_CONNTRACK_TOOLS=y BR2_PACKAGE_DNSMASQ=y BR2_PACKAGE_ETHTOOL=y BR2_PACKAGE_FPING=y +BR2_PACKAGE_FIREWALLD=y BR2_PACKAGE_FRR=y # BR2_PACKAGE_IFUPDOWN_SCRIPTS is not set BR2_PACKAGE_IPROUTE2=y diff --git a/configs/x86_64_defconfig b/configs/x86_64_defconfig index 54bb314b6..13c92d18a 100644 --- a/configs/x86_64_defconfig +++ b/configs/x86_64_defconfig @@ -69,6 +69,7 @@ BR2_PACKAGE_CONNTRACK_TOOLS=y BR2_PACKAGE_DNSMASQ=y BR2_PACKAGE_ETHTOOL=y BR2_PACKAGE_FPING=y +BR2_PACKAGE_FIREWALLD=y BR2_PACKAGE_FRR=y # BR2_PACKAGE_IFUPDOWN_SCRIPTS is not set BR2_PACKAGE_IPROUTE2=y diff --git a/configs/x86_64_minimal_defconfig b/configs/x86_64_minimal_defconfig index 37b200471..3ac9e55c5 100644 --- a/configs/x86_64_minimal_defconfig +++ b/configs/x86_64_minimal_defconfig @@ -65,6 +65,7 @@ BR2_PACKAGE_AVAHI_DEFAULT_SERVICES=y BR2_PACKAGE_CHRONY=y BR2_PACKAGE_DNSMASQ=y BR2_PACKAGE_ETHTOOL=y +BR2_PACKAGE_FIREWALLD=y BR2_PACKAGE_FRR=y # BR2_PACKAGE_IFUPDOWN_SCRIPTS is not set BR2_PACKAGE_IPROUTE2=y diff --git a/doc/TODO.org b/doc/TODO.org index f05fe9f94..f4c9d6e6e 100644 --- a/doc/TODO.org +++ b/doc/TODO.org @@ -1,3 +1,34 @@ +* TODO Add support for firewall +- [X] Interfaces are not defaulting to the default zone, must handle bridge + ports and changes to enslavement, so regenerate every time is a must! +- [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! + * TODO doc: User Guide - Feature set and scope, e.g. diff --git a/doc/firewall.md b/doc/firewall.md new file mode 100644 index 000000000..258cc1438 --- /dev/null +++ b/doc/firewall.md @@ -0,0 +1,238 @@ +# Firewall Documentation + +## Introduction + +The Infix firewall aims to simplify network security. Instead of complex +per-interface rules, you work with *zones*. A zone defines a level of trust +between all interfaces assigned to it and security policies regulate traffic +flow between zones. + +![Firewall](img/firewall.svg){ align=left width="180" } + +This approach is intuitive and maintainable — you think in terms of trust +relationships ("internal networks can access the Internet", "Internet cannot +access my internal network") rather than individual interface rules. When you +add new interfaces to existing zones, they automatically inherit the +established security policies. + +The firewall controls three distinct traffic flows: traffic destined for the +host itself, traffic between interfaces within the same zone, and traffic +between different zones. + +> [!TIP] Impatient and ready to get going? +> [Fast forward to the Examples: End Device, Home/Office Router, Enterprise Gateway](#examples) + +## Zones + +Zones are logical groupings of network interfaces that share the same trust +level. Each zone has a default *action* that determines what happens to +traffic destined for the host itself (INPUT chain). A LAN zone may have this +set to *accept*, while a DMZ zone may be set to *reject* by default and only +allow a subset of *services*, e.g., DHCP, DNS, and SSH, on input. + +### Intra-Zone Traffic + +For a LAN zone, the trust level can be set to allow IP routing between all +interfaces and networks. This is often referred to as *intra-zone* traffic +and is controlled by the zone's `forwarding` setting, it works independently +of the zone action. When enabled, devices on different interfaces within the +same zone can communicate directly with each other. + +> [!NOTE] Remember IP forwarding! +> Allowing forwarding between interfaces in the zone is not enough, this only +> prevents the firewall from actively blocking the traffic flows, you also +> need to enable [IP forwarding](network.md#ipv4-forwarding). + +### Port Forwarding + +Each zone can have port forwarding rules (DNAT) that redirect incoming traffic +from one port to a different internal address and port. This is effectively a +security policy that allows external access to internal services. + +The *Firewall Matrix* shows a ⚠ conditional warning flag, coloring the zone +yellow, when policy exceptions like port forwarding are active. + +![Firewall Matrix](img/fw-matrix.png) + +### Default Zone Concept + +Infix requires you to specify a default zone. Any interface not explicitly +assigned to a zone automatically belongs to the default zone. This ensures +that no interface is left without firewall protection. + +Choose your default zone carefully — it should be the most restrictive zone +appropriate for unmanaged interfaces. For routers, this is typically the +`wan` zone. + +## Policies + +![Zone based firewall](img/fw-zones.svg){ align=right width="420" } + +Policy rules control traffic **between** zones. By default all inter-zone +traffic is rejected. Meaning you must explicitly allow the traffic flows +you intend. + +IP masquerading (SNAT) is a policy setting that applies to traffic egressing +a target zone. (Essential for Internet access from private networks.) + +A policy, like zones, have a default action. If it is *not* set to `accept` +you must specify which services are allowed. + +See the [examples below](#enterprise-gateway) for how to set up a policy. The +built-in help system can also be useful: + +``` +admin@example:/config/firewall/policy/lan-to-dmz/> help masquerade +NAME + masquerade + +DESCRIPTION + Enable masquerading (SNAT) for traffic matching this policy. + +admin@example:/config/firewall/policy/lan-to-dmz/> +``` + +### Symbolic Names + +The symbolic names `HOST` and `ANY` are available for use in both `ingress` +and `egress` zones. In fact, the CLI uses inference when first enabling the +firewall to inject a default policy to allow an IPv6 autoconf address. + +### Custom Filters + +For more advanced firewall scenarios *custom filters* can be used. The only +support currently are various ICMP type traffic control. Enough to support +the default `allow-host-ipv6` policy. + +## Services + +Several pre-defined services exist, that cover most use-cases, but you can +also define custom services for applications not covered by the built-in ones. + +### Built-in services + +Infix provides predefined services for common protocols: + +- **`ssh`**: Secure Shell (port 22/tcp) +- **`http`**: Web traffic (port 80/tcp) +- **`https`**: Secure web traffic (port 443/tcp) +- **`dns`**: Domain Name System (port 53/tcp and 53/udp) +- **`dhcp`**: DHCP server (port 67/udp) +- **`dhcpv6-client`**: DHCPv6 client traffic +- **`netconf`**: Network Configuration Protocol (port 830/tcp) +- **`restconf`**: REST-based Network Configuration Protocol (port 443/tcp) + +... and more, see `infix-firewall-services.yang` for details + +## Examples + +### End Device Protection + +For devices on untrusted networks like public Wi-Fi or other open Internet +connections. Provides maximum protection while allowing essential +connectivity. + +``` +admin@example:/> configure +admin@example:/config/> edit firewall +admin@example:/config/firewall/> set default public +admin@example:/config/firewall/> edit zone public +admin@example:/config/firewall/zone/public/> set description "Public untrusted network - end device protection" +admin@example:/config/firewall/zone/public/> set action drop +admin@example:/config/firewall/zone/public/> set interface eth0 +admin@example:/config/firewall/zone/public/> set service ssh +admin@example:/config/firewall/zone/public/> set service dhcpv6-client +admin@example:/config/firewall/zone/public/> leave +``` + +### Home/Office Router + +For typical routers that need to protect internal devices while providing +internet access. The LAN zone trusts internal devices, while the WAN zone +blocks external threats. + +``` +admin@example:/> configure +admin@example:/config/> edit firewall +admin@example:/config/firewall/> set default wan +admin@example:/config/firewall/> edit zone lan +admin@example:/config/firewall/zone/lan/> set description "Internal LAN network - trusted" +admin@example:/config/firewall/zone/lan/> set action accept +admin@example:/config/firewall/zone/lan/> set interface eth1 +admin@example:/config/firewall/zone/lan/> set service ssh +admin@example:/config/firewall/zone/lan/> set service dhcp +admin@example:/config/firewall/zone/lan/> set service dns +admin@example:/config/firewall/zone/lan/> end +admin@example:/config/firewall/> edit zone wan +admin@example:/config/firewall/zone/wan/> set description "External WAN interface - untrusted" +admin@example:/config/firewall/zone/wan/> set action drop +admin@example:/config/firewall/zone/wan/> set interface eth0 +admin@example:/config/firewall/zone/wan/> end +admin@example:/config/firewall/> edit policy loc-to-wan +admin@example:/config/firewall/policy/loc-to-wan/> set description "Allow local LAN/DMZ traffic to WAN with SNAT" +admin@example:/config/firewall/policy/loc-to-wan/> set ingress lan +admin@example:/config/firewall/policy/loc-to-wan/> set egress wan +admin@example:/config/firewall/policy/loc-to-wan/> set action accept +admin@example:/config/firewall/policy/loc-to-wan/> set masquerade +admin@example:/config/firewall/policy/loc-to-wan/> leave +``` + +> [!NOTE] +> Policy rules apply in a stateful, unidirectional manner. Meaning, you only +> consider one direction of the traffic. The return traffic (established, +> related) is implicitly allowed. + +### Enterprise Gateway + +For businesses that need to host public services while protecting internal +resources. We can build upon the Home/Office Router example above and add +a DMZ zone with additional policies for controlled access. + +``` +admin@example:/> configure +admin@example:/config/> edit firewall zone dmz +admin@example:/config/firewall/zone/dmz/> set description "Semi-trusted public services" +admin@example:/config/firewall/zone/dmz/> set action drop +admin@example:/config/firewall/zone/dmz/> set interface eth1 +admin@example:/config/firewall/zone/dmz/> set service ssh +admin@example:/config/firewall/zone/dmz/> end +admin@example:/config/firewall/> edit policy lan-to-wan +admin@example:/config/firewall/policy/lan-to-wan/> set ingress dmz +admin@example:/config/firewall/policy/lan-to-wan/> end +admin@example:/config/firewall/> edit policy lan-to-dmz +admin@example:/config/firewall/policy/lan-to-dmz/> set description "Allow LAN to manage DMZ services" +admin@example:/config/firewall/policy/lan-to-dmz/> set ingress lan +admin@example:/config/firewall/policy/lan-to-dmz/> set egress dmz +admin@example:/config/firewall/policy/lan-to-dmz/> set action accept +admin@example:/config/firewall/policy/lan-to-dmz/> end +admin@example:/config/firewall/> edit zone wan port-forward 8080 tcp +admin@example:/config/firewall/zone/wan/port-forward/8080/tcp/> set to addr 192.168.2.10 +admin@example:/config/firewall/zone/wan/port-forward/8080/tcp/> set to port 80 +admin@example:/config/firewall/zone/wan/port-forward/8080/tcp/> leave +``` + +This adds a DMZ zone for public services, updates the internet access policy +to include DMZ traffic, allows LAN management of DMZ services, and forwards +external web traffic to the DMZ server. + +## Logging and Monitoring + +Different log levels are available to monitor and debug firewall behavior. +Configure logging using the CLI: + +``` +admin@example:/> configure +admin@example:/config/> edit firewall +admin@example:/config/firewall/> set logging all +admin@example:/config/firewall/> leave +``` + +Firewall logs help you understand traffic patterns and security events. The +CLI admin-exec command `show firewall` shows the last 10 log messages in the +overview: + +![Firewall logs](img/fw-logs.png) + +Use the command `show log firewall.log` to display the full logfile (remember, +the syslog daemon rotates and zips too big log files). You can also use the +`follow firewall.log` command to continuously monitor firewall log messages. diff --git a/doc/img/firewall.svg b/doc/img/firewall.svg new file mode 100644 index 000000000..bad820f1c --- /dev/null +++ b/doc/img/firewall.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/doc/img/fw-logs.png b/doc/img/fw-logs.png new file mode 100644 index 000000000..34e5f4471 Binary files /dev/null and b/doc/img/fw-logs.png differ diff --git a/doc/img/fw-matrix.png b/doc/img/fw-matrix.png new file mode 100644 index 000000000..c797c7b49 Binary files /dev/null and b/doc/img/fw-matrix.png differ diff --git a/doc/img/fw-zones.svg b/doc/img/fw-zones.svg new file mode 100644 index 000000000..82fa2fec7 --- /dev/null +++ b/doc/img/fw-zones.svg @@ -0,0 +1,4 @@ + + + +
port fwd
DMZ
LAN
WAN
loc-to-wan
lan-to-dmz
\ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 69a35d5c0..0b8ea73f5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -23,12 +23,13 @@ nav: - Keybindings: cli/keybindings.md - Network Calculator: cli/netcalc.md - Network Monitoring: cli/tcpdump.md - - Quickstart Guide: cli/quick.md + - Quickstart Guide: cli/quick.md - Text Editor: cli/text-editor.md - Upgrading: cli/upgrade.md - Docker Containers: container.md - Networking: - Network Configuration: networking.md + - Firewall Configuration: firewall.md - Quality of Service: qos.md - RMON Counters: eth-counters.md - Tunneling (L2/L3): tunnels.md diff --git a/package/confd/Config.in b/package/confd/Config.in index 984d7cdab..adb30e78c 100644 --- a/package/confd/Config.in +++ b/package/confd/Config.in @@ -5,6 +5,7 @@ config BR2_PACKAGE_CONFD select BR2_PACKAGE_NETOPEER2 select BR2_PACKAGE_SYSREPO select BR2_PACKAGE_LIBSRX + select BR2_PACKAGE_FIREWALLD help A plugin to sysrepo that provides the core YANG models used to manage an Infix based system. Configuration can be done using diff --git a/package/confd/confd.mk b/package/confd/confd.mk index 96024a1f0..847b1c55f 100644 --- a/package/confd/confd.mk +++ b/package/confd/confd.mk @@ -10,10 +10,13 @@ CONFD_SITE = $(BR2_EXTERNAL_INFIX_PATH)/src/confd CONFD_LICENSE = BSD-3-Clause CONFD_LICENSE_FILES = LICENSE CONFD_REDISTRIBUTE = NO -CONFD_DEPENDENCIES = host-sysrepo sysrepo netopeer2 jansson libite sysrepo libsrx libglib2 +CONFD_DEPENDENCIES = host-sysrepo sysrepo netopeer2 jansson libite sysrepo libsrx libglib2 firewalld CONFD_AUTORECONF = YES CONFD_CONF_OPTS += --disable-silent-rules --with-crypt=$(BR2_PACKAGE_CONFD_DEFAULT_CRYPT) CONFD_SYSREPO_SHM_PREFIX = sr_buildroot$(subst /,_,$(CONFIG_DIR))_confd +CONFD_FIREWALL_SERVICES_YANG = $(CONFD_SRCDIR)/yang/confd/infix-firewall-services.yang +CONFD_FIREWALL_XML_FILES="$(TARGET_DIR)/usr/lib/firewalld/policies/*.xml \ + $(TARGET_DIR)/usr/lib/firewalld/zones/*.xml" define CONFD_CONF_ENV CFLAGS="$(INFIX_CFLAGS)" @@ -100,8 +103,72 @@ define CONFD_EMPTY_SYSREPO rm -rf $(PER_PACKAGE_DIR)/host-sysrepo/target/etc/sysrepo/* $(PER_PACKAGE_DIR)/confd/target/etc/sysrepo/*; \ fi endef + +# The three zones that are *not* deleted from the default install +# are required by firewalld (core/fw.py), in particular block.xml +# Firewalld services cleanup: keep only services that match YANG enums, +# remove all others, and validate that all enums have corresponding .xml files define CONFD_CLEANUP rm -f /dev/shm/$(CONFD_SYSREPO_SHM_PREFIX)* + rm -rf $(TARGET_DIR)/etc/firewall* + rm -f $(TARGET_DIR)/usr/bin/firewall-applet + rm -rf $(TARGET_DIR)/usr/share/firewalld + rm -f $(TARGET_DIR)/usr/lib/firewalld/policies/* + find $(TARGET_DIR)/usr/lib/firewalld/zones -type f \ + ! -name block.xml \ + ! -name drop.xml \ + ! -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 $(TARGET_DIR)/usr/lib/firewalld/services + cp $(CONFD_PKGDIR)/netconf.xml $(TARGET_DIR)/usr/lib/firewalld/services/ + cp $(CONFD_PKGDIR)/restconf.xml $(TARGET_DIR)/usr/lib/firewalld/services/ + # Find all pre-defined services in our YANG services model, + # drop firewalld service.xml that are *not* enumerated. + if [ ! -f "$(CONFD_FIREWALL_SERVICES_YANG)" ]; then \ + echo "ERROR: $(CONFD_FIREWALL_SERVICES_YANG) not found"; \ + exit 1; \ + fi; \ + ENUMS=$$(grep 'enum "' $(CONFD_FIREWALL_SERVICES_YANG) | \ + sed 's/.*enum "\([^"]*\)".*/\1/'); \ + MISSING=0; \ + for service in $$ENUMS; do \ + if [ ! -f "$(TARGET_DIR)/usr/lib/firewalld/services/$$service.xml" ]; then \ + echo "Service $$service is not a firewalld pre-defined service"; \ + MISSING=1; \ + fi; \ + done; \ + if [ $$MISSING -eq 1 ]; then \ + exit 1; \ + fi; \ + cd $(TARGET_DIR)/usr/lib/firewalld/services/; \ + for xmlfile in *.xml; do \ + service=$${xmlfile%.xml}; \ + if ! echo "$$ENUMS" | grep -q "^$$service$$"; then \ + rm "$$xmlfile"; \ + fi; \ + done + for xmlfile in $$CONFD_FIREWALL_XML_FILES; do \ + [ -f "$$xmlfile" ] || continue; \ + if grep -q "(immutable)" "$$xmlfile"; then \ + continue; \ + fi; \ + if grep -q '' "$$xmlfile"; then \ + sed -i 's|\(.*\)|\1 (immutable)|' \ + "$$xmlfile"; \ + else \ + if echo "$$xmlfile" | grep -q "/policies/"; then \ + sed -i 's|(immutable)\n&|' \ + "$$xmlfile"; \ + else \ + sed -i 's|(immutable)\n&|' \ + "$$xmlfile"; \ + fi; \ + fi; \ + done endef CONFD_PRE_BUILD_HOOKS += CONFD_EMPTY_SYSREPO CONFD_PRE_BUILD_HOOKS += CONFD_CLEANUP diff --git a/package/confd/netconf.xml b/package/confd/netconf.xml new file mode 100644 index 000000000..3a4039724 --- /dev/null +++ b/package/confd/netconf.xml @@ -0,0 +1,11 @@ + + + NETCONF + + NETCONF (Network Configuration Protocol) is a protocol for configuration + and monitoring of networked devices. Essentially it can be seen as XML + over SSH, for configuration and state/status, it also support RPC calls + (Remote Procedure Call), e.g., set date-time or reboot device. + + + diff --git a/package/confd/restconf.xml b/package/confd/restconf.xml new file mode 100644 index 000000000..205e9fc6c --- /dev/null +++ b/package/confd/restconf.xml @@ -0,0 +1,12 @@ + + + RESTCONF + + RESTCONF (RESTful Network Configuration Protocol) is a JSON over + HTTP-based protocol that provides a RESTful API for configuration + and operational data, as well as RPCs. Like NETCONF, but it can + be managed using only curl. + + + + diff --git a/package/finit/finit.hash b/package/finit/finit.hash index 9f2bd7594..cdb2a984a 100644 --- a/package/finit/finit.hash +++ b/package/finit/finit.hash @@ -1,8 +1,8 @@ # From https://github.com/troglobit/finit/releases/ -sha256 b6a0a2f98c860cf9fe5dfe7e3601d922957ad7880ae29919176ab960b7b96e70 finit-4.12.tar.gz +sha256 d49c9b9253c54c958da75d41436c2845218fa2bb20248a319e931d924b3ba6cb finit-4.13.tar.gz # Locally calculated -sha256 2fd62c0fe6ea6d1861669f4c87bda83a0b5ceca64f4baa4d16dd078fbd218c14 LICENSE +sha256 868cb6c5414933a48db11186042cfe65c87480d326734bc6cf0e4b19b4a2e52a LICENSE # GIT Snapshot sha256 8c880293409cf566f6256bff193f985c50bd2eb99d2ff964dcaa9590251ed27e finit-438d6b4e638418a2a22024a3cead2f47909d72b9.tar.gz diff --git a/package/finit/finit.mk b/package/finit/finit.mk index 50959feac..581049515 100644 --- a/package/finit/finit.mk +++ b/package/finit/finit.mk @@ -4,7 +4,7 @@ # ################################################################################ -FINIT_VERSION = 4.12 +FINIT_VERSION = 4.13 FINIT_SITE = https://github.com/troglobit/finit/releases/download/$(FINIT_VERSION) FINIT_LICENSE = MIT FINIT_LICENSE_FILES = LICENSE @@ -39,6 +39,7 @@ FINIT_CONF_OPTS = \ --disable-contrib \ --disable-rescue \ --disable-silent-rules \ + --without-libsystemd \ --with-group="$(FINIT_GROUP)" ifeq ($(BR2_ROOTFS_MERGED_USR),y) diff --git a/src/confd/bin/Makefile.am b/src/confd/bin/Makefile.am index 925e5130c..49bed009d 100644 --- a/src/confd/bin/Makefile.am +++ b/src/confd/bin/Makefile.am @@ -1,4 +1,4 @@ pkglibexec_SCRIPTS = bootstrap error load gen-service gen-hostname \ gen-interfaces gen-motd gen-hardware gen-version \ mstpd-wait-online wait-interface -sbin_SCRIPTS = dagger migrate +sbin_SCRIPTS = dagger migrate firewall diff --git a/src/confd/bin/firewall b/src/confd/bin/firewall new file mode 100755 index 000000000..6c6f9ef07 --- /dev/null +++ b/src/confd/bin/firewall @@ -0,0 +1,369 @@ +#!/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 "'" '"') + + if echo "$zones_output" | jq -e . >/dev/null 2>&1; then + echo "$zones_output" | jq -r 'to_entries[] | " \(.key): \(.value.interfaces // [] | join(", "))"' 2>/dev/null || echo " Failed to parse zones" + else + echo " No zones or failed to retrieve" + fi + echo + + 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 + + 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 + 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 87beb5920..29466e992 100644 --- a/src/confd/src/Makefile.am +++ b/src/confd/src/Makefile.am @@ -47,6 +47,7 @@ confd_plugin_la_SOURCES = \ infix-dhcp-client.c \ infix-dhcp-server.c \ infix-factory.c \ + infix-firewall.c \ infix-meta.c \ infix-services.c \ infix-system-software.c \ diff --git a/src/confd/src/core.c b/src/confd/src/core.c index ba987f4b0..49fb59fe7 100644 --- a/src/confd/src/core.c +++ b/src/confd/src/core.c @@ -172,6 +172,9 @@ int sr_plugin_init_cb(sr_session_ctx_t *session, void **priv) if (rc) goto err; rc = ietf_hardware_init(&confd); + if (rc) + goto err; + rc = infix_firewall_init(&confd); if (rc) goto err; diff --git a/src/confd/src/core.h b/src/confd/src/core.h index ffe08309d..90595595c 100644 --- a/src/confd/src/core.h +++ b/src/confd/src/core.h @@ -256,4 +256,7 @@ int ietf_hardware_init(struct confd *confd); /* ietf-keystore.c */ int ietf_keystore_init(struct confd *confd); +/* infix-firewall.c */ +int infix_firewall_init(struct confd *confd); + #endif /* CONFD_CORE_H_ */ diff --git a/src/confd/src/ietf-interfaces.c b/src/confd/src/ietf-interfaces.c index 28045ff05..574444dba 100644 --- a/src/confd/src/ietf-interfaces.c +++ b/src/confd/src/ietf-interfaces.c @@ -918,6 +918,72 @@ static int keystorecb(sr_session_ctx_t *session, uint32_t sub_id, const char *mo return err; } +int ietf_interfaces_get_all_l3(const struct lyd_node *tree, char ***ifaces) +{ + struct lyd_node *interfaces, *cif; + char **names = NULL; + size_t capacity = 0; + size_t num = 0; + const char *ifname; + + if (!tree || !ifaces) + return -EINVAL; + + *ifaces = NULL; + + interfaces = lydx_get_descendant((struct lyd_node *)tree, "interfaces", "interface", NULL); + if (!interfaces) { + *ifaces = calloc(1, sizeof(char *)); + return *ifaces ? 0 : -ENOMEM; + } + + LYX_LIST_FOR_EACH(interfaces, cif, "interface") { + ifname = lydx_get_cattr(cif, "name"); + if (!ifname) + continue; + + if (is_member_port(cif)) + continue; + + if (iftype_from_iface(cif) == IFT_LO) + continue; + + if (lydx_get_child(cif, "container-network")) + continue; + + if (num + 1 >= capacity) { + capacity = capacity ? capacity * 2 : 8; + char **new_names = realloc(names, capacity * sizeof(char *)); + if (!new_names) { + for (size_t i = 0; i < num; i++) + free(names[i]); + free(names); + return -ENOMEM; + } + names = new_names; + } + + names[num] = strdup(ifname); + if (!names[num]) { + for (size_t i = 0; i < num; i++) + free(names[i]); + free(names); + return -ENOMEM; + } + + num++; + } + + if (num == 0) { + *ifaces = calloc(1, sizeof(char *)); + return *ifaces ? 0 : -ENOMEM; + } + + names[num] = NULL; + *ifaces = names; + return 0; +} + int ietf_interfaces_init(struct confd *confd) { int rc; diff --git a/src/confd/src/ietf-interfaces.h b/src/confd/src/ietf-interfaces.h index 81892ff26..655f6a836 100644 --- a/src/confd/src/ietf-interfaces.h +++ b/src/confd/src/ietf-interfaces.h @@ -102,6 +102,7 @@ int netdag_gen_ethtool(struct dagger *net, struct lyd_node *cif, struct lyd_node /* ietf-interfaces.c */ const char *get_chassis_addr(void); int link_gen_address(struct lyd_node *cif, FILE *ip); +int ietf_interfaces_get_all_l3(const struct lyd_node *tree, char ***ifaces); /* ietf-ip.c */ int netdag_gen_ipv6_autoconf(struct dagger *net, struct lyd_node *cif, diff --git a/src/confd/src/infix-dhcp-server.c b/src/confd/src/infix-dhcp-server.c index a942ddf11..a0bce16f9 100644 --- a/src/confd/src/infix-dhcp-server.c +++ b/src/confd/src/infix-dhcp-server.c @@ -398,7 +398,7 @@ static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module static int cand(sr_session_ctx_t *session, uint32_t sub_id, const char *module, const char *path, sr_event_t event, unsigned request_id, void *priv) { - const char *fmt = "/infix-dhcp-server:dhcp-server/option[id='%s']/address"; + const char *fmt = CFG_XPATH "/option[id='%s']/address"; sr_val_t inferred = { .type = SR_STRING_T }; const char *opt[] = { "router", @@ -409,7 +409,7 @@ static int cand(sr_session_ctx_t *session, uint32_t sub_id, const char *module, if (event != SR_EV_UPDATE && event != SR_EV_CHANGE) return 0; - if (srx_nitems(session, &cnt, "/infix-dhcp-server:dhcp-server/option") || cnt) + if (srx_nitems(session, &cnt, CFG_XPATH "/option") || cnt) return 0; for (i = 0; i < NELEMS(opt); i++) { @@ -422,7 +422,7 @@ static int cand(sr_session_ctx_t *session, uint32_t sub_id, const char *module, static int clear_stats(sr_session_ctx_t *session, uint32_t sub_id, const char *xpath, const sr_val_t *input, const size_t input_cnt, sr_event_t event, - unsigned request_id, sr_val_t **output, size_t *output_cnt, void *priv) + uint32_t request_id, sr_val_t **output, size_t *output_cnt, void *priv) { if (systemf("dbus-send --system --dest=uk.org.thekelleys.dnsmasq " "/uk/org/thekelleys/dnsmasq uk.org.thekelleys.dnsmasq.ClearMetrics")) @@ -436,7 +436,7 @@ int infix_dhcp_server_init(struct confd *confd) int rc; REGISTER_CHANGE(confd->session, MODULE, CFG_XPATH, 0, change, confd, &confd->sub); - REGISTER_CHANGE(confd->cand, MODULE, CFG_XPATH"//.", SR_SUBSCR_UPDATE, cand, confd, &confd->sub); + REGISTER_CHANGE(confd->cand, MODULE, CFG_XPATH "//.", SR_SUBSCR_UPDATE, cand, confd, &confd->sub); REGISTER_RPC(confd->session, CFG_XPATH "/statistics/clear", clear_stats, NULL, &confd->sub); return SR_ERR_OK; diff --git a/src/confd/src/infix-firewall.c b/src/confd/src/infix-firewall.c new file mode 100644 index 000000000..7ea5dda6b --- /dev/null +++ b/src/confd/src/infix-firewall.c @@ -0,0 +1,733 @@ +/* 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 FIREWALLD_DIR "/etc/firewalld" +#define FIREWALLD_CONF FIREWALLD_DIR "/firewalld.conf" +#define FIREWALLD_ZONES_DIR FIREWALLD_DIR "/zones" +#define FIREWALLD_SERVICES_DIR FIREWALLD_DIR "/services" +#define FIREWALLD_POLICIES_DIR FIREWALLD_DIR "/policies" + +static struct { + const char *yang; + const char *target; +} zone_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 cleanup_files(void) +{ + const char *dirs[] = { + FIREWALLD_ZONES_DIR, + FIREWALLD_SERVICES_DIR, + FIREWALLD_POLICIES_DIR, + NULL + }; + + if (erasef(FIREWALLD_CONF) && errno != ENOENT) + ERRNO("Failed removing %s: %s", FIREWALLD_CONF, strerror(errno)); + + for (int i = 0; dirs[i]; i++) { + struct dirent *entry; + DIR *d; + + d = opendir(dirs[i]); + if (!d) { + DEBUG("Directory %s does not exist, skipping cleanup", dirs[i]); + continue; + } + + while ((entry = readdir(d)) != NULL) { + if (entry->d_type != DT_REG) + continue; + + DEBUG("Removing firewalld file: %s/%s", dirs[i], entry->d_name); + if (erasef("%s/%s", dirs[i], entry->d_name) && errno != ENOENT) + ERRNO("Failed removing %s/%s: %s", dirs[i], entry->d_name, strerror(errno)); + } + + closedir(d); + } + + return SR_ERR_OK; +} + +static int generate_zone(struct lyd_node *cfg, const char *name, char **ifaces) +{ + const char *action, *desc; + 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: calculate destination upper port */ + int lower_val = atoi(lower); + int upper_val = atoi(upper); + int to_port_val = to_port ? atoi(to_port) : lower_val; + int range_size = upper_val - lower_val; + int to_upper = to_port_val + range_size; + + fprintf(fp, " \n"); + } else { + /* Single port */ + fprintf(fp, " \n"); + } + } + } + + if (lydx_is_enabled(cfg, "forwarding")) + 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, "MinimalMark=100\n"); + fprintf(fp, "CleanupOnExit=yes\n"); + fprintf(fp, "Lockdown=no\n"); + fprintf(fp, "IPv6_rpfilter=yes\n"); + fprintf(fp, "IndividualCalls=no\n"); + fprintf(fp, "LogDenied=%s\n", lydx_get_cattr(cfg, "logging") ?: "off"); + fprintf(fp, "AutomaticHelpers=system\n"); + fprintf(fp, "FirewallBackend=nftables\n"); + fprintf(fp, "FlushAllOnReload=yes\n"); + fprintf(fp, "RFC3964_IPv4=yes\n"); + fclose(fp); + + return SR_ERR_OK; +} + +static int infer_zone(sr_session_ctx_t *session, const char *name, const char *desc, + const char *action, bool forwarding, const char *services[]) +{ + int rc; + + DEBUG("Inferring zone %s (%s), action %s forwarding %d", name, desc, action, forwarding); + + 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; + + rc = srx_set_bool(session, forwarding, 0, XPATH "/zone[name='%s']/forwarding", 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; +} + +static int infer_policy(sr_session_ctx_t *session, const char *name, const char *desc, + const char *action, const char *ingress[], const char *egress[], + const char *icmp_types[][4]) +{ + int rc; + + DEBUG("Inferring policy %s (%s), action %s", name, desc, action); + + rc = srx_set_str(session, desc, 0, XPATH "/policy[name='%s']/description", name); + if (rc) + return rc; + + rc = srx_set_str(session, action, 0, XPATH "/policy[name='%s']/action", name); + if (rc) + return rc; + + /* Set ingress zones */ + for (int i = 0; ingress && ingress[i]; i++) { + rc = srx_set_str(session, ingress[i], 0, XPATH "/policy[name='%s']/ingress[.='%s']", + name, ingress[i]); + if (rc) + return rc; + } + + /* Set egress zones */ + for (int i = 0; egress && egress[i]; i++) { + rc = srx_set_str(session, egress[i], 0, XPATH "/policy[name='%s']/egress[.='%s']", + name, egress[i]); + if (rc) + return rc; + } + + /* Set custom ICMP filters */ + for (int i = 0; icmp_types && icmp_types[i][0]; i++) { + const char *family = icmp_types[i][0]; + const char *filter = icmp_types[i][1]; + const char *action = icmp_types[i][2]; + const char *type = icmp_types[i][3]; + + rc = srx_set_str(session, family, 0, + XPATH "/policy[name='%s']/custom/filter[name='%s']/family", + name, filter); + if (rc) + return rc; + + rc = srx_set_str(session, action, 0, + XPATH "/policy[name='%s']/custom/filter[name='%s']/action", + name, filter); + if (rc) + return rc; + + rc = srx_set_str(session, type, 0, + XPATH "/policy[name='%s']/custom/filter[name='%s']/icmp/type", + name, filter); + if (rc) + return rc; + } + + return SR_ERR_OK; +} + +static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module, + const char *xpath, sr_event_t event, unsigned request_id, void *_confd) +{ + struct lyd_node *diff, *tree, *global; + struct lyd_node *clist, *cnode; + bool reload_needed = false; + sr_error_t err = SR_ERR_OK; + char **ifaces = NULL; + sr_data_t *cfg; + + switch (event) { + case SR_EV_CHANGE: + /* Validation phase - just return OK for now */ + return SR_ERR_OK; + + case SR_EV_ABORT: + default: + return SR_ERR_OK; + + case SR_EV_DONE: + break; + } + + err = sr_get_data(session, "//.", 0, 0, 0, &cfg); + if (err || !cfg) + return SR_ERR_INTERNAL; + + tree = cfg->tree; + global = lydx_get_descendant(tree, "firewall", NULL); + + /* Get L3 interfaces for default zone assignment */ + 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; + + if (!global) { + /* Firewall is dactivated - clean up all firewalld configuration files */ + cleanup_files(); + goto done; + } + + if (lydx_get_descendant(diff, "firewall", "default", NULL) || + lydx_get_descendant(diff, "firewall", "logging", NULL)) { + generate_firewalld_conf(global); + reload_needed = true; + } + + /* Simplified approach: regenerate everything if anything in firewall changed */ + if (lydx_get_descendant(diff, "firewall", NULL)) { + const char *default_zone = lydx_get_cattr(global, "default"); + struct lyd_node *list, *node; + int priority = -32768; + + /* 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; + } + } + + reload_needed = true; + } + + if (reload_needed) + system("initctl -nbq touch firewalld"); + + + /* We check 'enabled' here to allow us to debug generated files. */ + if (!lydx_is_enabled(global, "enabled")) { + global = NULL; + goto done; + } + +done: + systemf("initctl -nbq %s firewalld", global ? "enable" : "disable"); + + if (ifaces) { + for (int i = 0; ifaces[i]; i++) + free(ifaces[i]); + free(ifaces); + } + + lyd_free_tree(diff); +err_release_data: + 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}; + const char *any[] = {"ANY", NULL}; + const char *host[] = {"HOST", NULL}; + const char *icmp_types[][4] = { + {"ipv6", "na", "accept", "neighbour-advertisement"}, + {"ipv6", "ns", "accept", "neighbour-solicitation"}, + {"ipv6", "ra", "accept", "router-advertisement"}, + {"ipv6", "re", "accept", "redirect"}, + {NULL, NULL, NULL, NULL} + }; + size_t cnt = 0; + int rc; + + if (event != SR_EV_UPDATE && event != SR_EV_CHANGE) + return 0; + + if (!srx_enabled(session, XPATH "/enabled")) { + DEBUG("Deleted, or not enabled, not inferring anything."); + return 0; + } + + /* If unset, this is the first time we're called */ + if (srx_get_str(session, XPATH "/default")) + return 0; + + rc = srx_nitems(session, &cnt, XPATH "/zones"); + if (rc == 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", false, 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; + + /* 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; + + 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 f260c53a6..ff1b0aab9 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -28,6 +28,8 @@ 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-meta@2024-10-18.yang" "infix-system@2025-01-25.yang" "infix-services@2024-12-03.yang" diff --git a/src/confd/yang/confd/infix-firewall-services.yang b/src/confd/yang/confd/infix-firewall-services.yang new file mode 100644 index 000000000..c95589edc --- /dev/null +++ b/src/confd/yang/confd/infix-firewall-services.yang @@ -0,0 +1,149 @@ +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 "bgp" { + description "179/tcp — Border Gateway Protocol for internet routing"; + } + 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 "ftp" { + description "20-21/tcp — File Transfer Protocol for file transfers"; + } + 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 "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 "kerberos" { + description "88/tcp+udp — Network authentication protocol"; + } + enum "ldap" { + description "389/tcp — Lightweight Directory Access Protocol for directory services"; + } + enum "mdns" { + description "5353/udp — Multicast DNS for local network service discovery"; + } + enum "mongodb" { + description "27017/tcp — Document-oriented NoSQL database"; + } + enum "mqtt" { + description "1883/tcp — Message Queuing Telemetry Transport for IoT"; + } + enum "mssql" { + description "1433/tcp — Microsoft SQL Server database"; + } + enum "mysql" { + description "3306/tcp — MySQL database server connections"; + } + 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 "ntp" { + description "123/udp — Network Time Protocol for time synchronization"; + } + enum "openvpn" { + description "1194/udp — OpenVPN secure tunnel for VPN connections"; + } + 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 "postgresql" { + description "5432/tcp — PostgreSQL database server connections"; + } + enum "radius" { + description "1812-1813/tcp+udp — Remote Authentication Dial-in User Service"; + } + enum "rdp" { + description "3389/tcp — Remote Desktop Protocol for Windows remote access"; + } + enum "restconf" { + description "443/tcp — RESTful Network Configuration Protocol for HTTP-based network management"; + } + enum "samba" { + description "445/tcp — Windows file and printer sharing"; + } + enum "samba-client" { + description "138/udp — Windows file and printer sharing (client-only)"; + } + 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 "snmp" { + description "161/udp — Simple Network Management Protocol for network monitoring"; + } + enum "snmptrap" { + description "162/udp — Simple Network Management Protocol trap notifications"; + } + enum "ssh" { + description "22/tcp — Secure Shell for remote login and command execution"; + } + 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 "vnc-server" { + description "5900-5906/tcp — Virtual Network Computing server for remote desktop access"; + } + 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"; + } + } + } +} diff --git a/src/confd/yang/confd/infix-firewall-services@2025-04-26.yang b/src/confd/yang/confd/infix-firewall-services@2025-04-26.yang new file mode 120000 index 000000000..12d91ca31 --- /dev/null +++ b/src/confd/yang/confd/infix-firewall-services@2025-04-26.yang @@ -0,0 +1 @@ +infix-firewall-services.yang \ No newline at end of file diff --git a/src/confd/yang/confd/infix-firewall.yang b/src/confd/yang/confd/infix-firewall.yang new file mode 100644 index 000000000..296813ccb --- /dev/null +++ b/src/confd/yang/confd/infix-firewall.yang @@ -0,0 +1,593 @@ +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-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 string { + pattern 'HOST|ANY'; + } + 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."; + } + } + } + + /* firewall-cmd --get-icmptypes */ + typedef icmp-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 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."; + } + } + } + + /* + * Main container and configuration + */ + + container firewall { + description "Zone-based firewall configuration."; + presence "Activte 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 "Log level for denied (rejected/dropped) packets."; + type enumeration { + enum all { + description "Log all denied packets."; + } + enum unicast { + description "Log unicast denied packets."; + } + enum broadcast { + description "Log broadcast denied packets."; + } + enum multicast { + description "Log multicast denied packets."; + } + enum off { + description "Do not log denied packets."; + } + } + default off; + } + + list zone { + description "A zone defines a level of trust for network connections."; + key "name"; + + leaf name { + description "Name of the zone."; + type ident; + } + + leaf action { + description "Default for ingressing traffic not matching an explicit rule. + + Note, see also the 'forwarding' setting for this zone."; + type zone-action; + default reject; + } + + leaf immutable { + description "Indicates if this zone is read-only/system-defined and cannot be modified."; + config false; + 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 forwarding { + description "Allow forwarding between interfaces/networks in the same zone (intra-zone). + + Note, this is a policy rule that applies before the zone action!"; + type boolean; + } + + list port-forward { + description "Forward traffic another port and/or host (DNAT)."; + key "lower proto"; + + leaf lower { + description "Local port to forward from."; + type inet:port-number; + } + + leaf upper { + description "Upper port when forwarding a range of ports."; + type inet:port-number; + must "../lower <= ."; + } + + leaf proto { + description "Network protocol."; + type protocol-type; + } + + container to { + description "Forward matching traffic to given address and port."; + leaf addr { + description "IP address to forward to."; + type inet:ip-address; + } + + leaf port { + description "Port to forward to. + + When forwarding a range of ports this is the lower + and the upper is then automatically calculated."; + type inet:port-number; + } + } + } + + leaf-list service { + description "Services allowed to ingress zone when action != accept."; + type union { + type leafref { + path "../../service/name"; + } + type ifw-svc:well-known-service; + } + } + } + + list policy { + description "Rules for filtering traffic forwarded between zones (inter-zone)."; + ordered-by user; + key "name"; + + leaf name { + description "Unique identifier (filename) for this policy, e.g., LAN-to-WAN."; + type ident; + } + + leaf action { + description "Action for non-matching traffic. All policies are terminal (accept/reject/drop)."; + type policy-action; + default "reject"; + } + + leaf immutable { + description "Indicates if this policy is read-only/system-defined and cannot be modified."; + config false; + 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."; + type boolean; + } + + leaf-list service { + description "Allowed services, all other traffic follows the policy default action."; + 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"; + + leaf name { + description "Unique identifier for this filter within the policy."; + type ident; + } + + leaf family { + description "Address family selector."; + type enumeration { enum ipv4; enum ipv6; enum both; } + default both; + } + + choice type { + description "Type of traffic to match for this filter."; + + case icmp { + container icmp { + leaf type { + description "ICMP type to match."; + type icmp-type; + } + } + } + } + + leaf action { + description "How to handle filter matches."; + // XXX: Different from similar enums because we may add 'mark' later + type enumeration { enum accept; enum drop; enum reject; } + default accept; + } + + leaf priority { + // Sorting order as read from firewalld + description "Effective priority of this filter."; + config false; + type int16; + } + } + } + + 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 000000000..6e6fada47 --- /dev/null +++ b/src/confd/yang/confd/infix-firewall@2025-04-26.yang @@ -0,0 +1 @@ +infix-firewall.yang \ No newline at end of file diff --git a/src/klish-plugin-infix/src/infix.c b/src/klish-plugin-infix/src/infix.c index 54bae6554..0232e3d87 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/,)$//' | 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 1ce5ff1a4..728ed6737 100644 --- a/src/klish-plugin-infix/xml/infix.xml +++ b/src/klish-plugin-infix/xml/infix.xml @@ -113,6 +113,27 @@ + + + + + + + + + + + + + + + + + + + + + @@ -317,8 +338,7 @@ - - + @@ -389,8 +409,7 @@ - - + @@ -492,6 +511,38 @@ jq -C . /cfg/startup-config.cfg |pager + + + + + + + + + sysrepocfg -X -d operational -x /infix-firewall:firewall -f json -t 60 | /usr/libexec/statd/cli-pretty show-firewall-zone "$KLISH_PARAM_name" |pager + + + + + + + + sysrepocfg -X -d operational -x /infix-firewall:firewall -f json -t 60 | /usr/libexec/statd/cli-pretty show-firewall-policy "$KLISH_PARAM_name" |pager + + + + + + + + sysrepocfg -X -d operational -x /infix-firewall:firewall -f json -t 60 | /usr/libexec/statd/cli-pretty show-firewall-service "$KLISH_PARAM_name" |pager + + + + + sysrepocfg -X -d operational -x /infix-firewall:firewall -f json -t 60 | /usr/libexec/statd/cli-pretty show-firewall |pager + + @@ -501,6 +552,25 @@ /ietf-factory-default:factory-reset + + + + + now + cancel + + + + if [ "${KLISH_PARAM_operation}" = "now" ]; then + if ! firewall panic status; then + /bin/yorn -q "WARNING: This will block ALL network traffic and sever existing connections" + fi + fi + + /infix-firewall:firewall/lockdown-mode + + + @@ -555,6 +625,19 @@ + + + + reset + + + + + resize >/dev/null + + + + diff --git a/src/libsrx/src/srx_val.c b/src/libsrx/src/srx_val.c index 961e72fa2..ac8204588 100644 --- a/src/libsrx/src/srx_val.c +++ b/src/libsrx/src/srx_val.c @@ -149,28 +149,59 @@ bool srx_isset(sr_session_ctx_t *session, const char *fmt, ...) return isset; } -int srx_set_item(sr_session_ctx_t *session, const sr_val_t *val, sr_edit_options_t opts, - const char *fmt, ...) +static int set_vaitem(sr_session_ctx_t *session, const sr_val_t *val, sr_edit_options_t opts, + const char *fmt, va_list ap) { + va_list apdup; char *xpath; - va_list ap; size_t len; - va_start(ap, fmt); - len = vsnprintf(NULL, 0, fmt, ap) + 1; - va_end(ap); + va_copy(apdup, ap); + len = vsnprintf(NULL, 0, fmt, apdup) + 1; + va_end(apdup); xpath = alloca(len); if (!xpath) return -1; + va_copy(apdup, ap); + vsnprintf(xpath, len, fmt, apdup); + va_end(apdup); + + return sr_set_item(session, xpath, val, opts); +} + +int srx_set_item(sr_session_ctx_t *session, const sr_val_t *val, sr_edit_options_t opts, + const char *fmt, ...) +{ + va_list ap; + int rc; + va_start(ap, fmt); - vsnprintf(xpath, len, fmt, ap); + rc = set_vaitem(session, val, opts, fmt, ap); va_end(ap); - return sr_set_item(session, xpath, val, opts); + return rc; } +int srx_set_bool(sr_session_ctx_t *session, bool ena, sr_edit_options_t opts, + const char *fmt, ...) +{ + sr_val_t val = { + .type = SR_BOOL_T, + .data.bool_val = ena + }; + va_list ap; + int rc; + + va_start(ap, fmt); + rc = set_vaitem(session, &val, opts, fmt, ap); + va_end(ap); + + return rc; +} + + int srx_set_str(sr_session_ctx_t *session, const char *str, sr_edit_options_t opts, const char *fmt, ...) { diff --git a/src/libsrx/src/srx_val.h b/src/libsrx/src/srx_val.h index e95a3f79a..1b2d1adaa 100644 --- a/src/libsrx/src/srx_val.h +++ b/src/libsrx/src/srx_val.h @@ -20,6 +20,8 @@ int srx_set_item(sr_session_ctx_t *, const sr_val_t *, sr_edit_options_t, const __attribute__ ((format (printf, 4, 5))); int srx_set_str(sr_session_ctx_t *, const char *, sr_edit_options_t, const char *fmt, ...) __attribute__ ((format (printf, 4, 5))); +int srx_set_bool(sr_session_ctx_t *session, bool ena, sr_edit_options_t opts, const char *fmt, ...) + __attribute__ ((format (printf, 4, 5))); char *srx_get_str (sr_session_ctx_t *session, const char *fmt, ...) __attribute__ ((format (printf, 2, 3))); diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index 232818ce3..6ecc539f9 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -3,10 +3,88 @@ 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): + """Compress a list of interfaces into ranges where possible. + + Examples: + ['e1', 'e2', 'e3', 'e4'] -> 'e1-e4' + ['e1', 'e2', 'e4', 'e5'] -> 'e1-e2, e4-e5' + ['e1', 'e3', 'e5'] -> 'e1, e3, e5' + ['eth0', 'eth1', 'br0'] -> 'eth0-eth1, br0' + """ + if not interfaces: + return "" + + if len(interfaces) == 1: + return interfaces[0] + + # Group interfaces by their prefix (e.g., 'e', 'eth', 'br') + groups = {} + standalone = [] + + for iface in interfaces: + # Extract prefix and number using regex + match = re.match(r'^([a-zA-Z]+)(\d+)$', iface) + if match: + prefix = match.group(1) + number = int(match.group(2)) + if prefix not in groups: + groups[prefix] = [] + groups[prefix].append((number, iface)) + else: + # Interface doesn't follow prefix+number pattern + standalone.append(iface) + + # Process each group to find ranges + result_parts = [] + + for prefix in sorted(groups.keys()): + # Sort by number + numbers_and_ifaces = sorted(groups[prefix]) + ranges = [] + start = None + end = None + + for number, iface in numbers_and_ifaces: + if start is None: + # Start new range + start = number + end = number + elif number == end + 1: + # Extend current range + end = number + else: + # End current range and start new one + if start == end: + ranges.append(f"{prefix}{start}") + else: + ranges.append(f"{prefix}{start}-{prefix}{end}") + start = number + end = number + + # Add the final range + if start is not None: + if start == end: + ranges.append(f"{prefix}{start}") + else: + ranges.append(f"{prefix}{start}-{prefix}{end}") + + result_parts.extend(ranges) + + # Add standalone interfaces + result_parts.extend(sorted(standalone)) + + return ", ".join(result_parts) + + class Pad: iface = 16 proto = 11 @@ -96,6 +174,45 @@ class PadLldp: port_id = 20 +class PadFirewall: + zone_locked = 2 + zone_name = 20 + zone_action = 9 + zone_interfaces = 30 + zone_services = 30 + + zone_pfwd_from = 20 + zone_pfwd_to = 69 + + zone_flow_to = 20 + zone_flow_action = 14 + zone_flow_policy = 20 + zone_flow_services = 45 + + policy_locked = 2 + policy_name = 20 + policy_action = 9 + policy_ingress = 30 + policy_egress = 30 + + service_name = 20 + service_ports = 69 + + # Firewall log display formatting + log_time = 16 # ISO format: MM-DD HH:MM:SS + log_action = 7 # REJECT/DROP + small buffer + log_src = 26 # IPv6 addresses (shortened) or IPv4 + log_dst = 26 # IPv6 addresses (shortened) or IPv4 + log_proto = 6 # TCP/UDP/ICMP + small buffer + log_port = 5 # Port numbers + small buffer + + @classmethod + def table_width(cls): + """Table width for zones/policies tables, used to center matrix""" + return cls.zone_locked + cls.zone_name + cls.zone_action + cls.zone_interfaces \ + + cls.zone_services + + class Decore(): @staticmethod def decorate(sgr, txt, restore="0"): @@ -125,6 +242,14 @@ def bright_green(txt): def yellow(txt): return Decore.decorate("33", txt, "39") + @staticmethod + def bold_yellow(txt): + return Decore.decorate("1;33", txt, "0") + + @staticmethod + def flashing_red(txt): + return Decore.decorate("5;31", txt, "0") + @staticmethod def underline(txt): return Decore.decorate("4", txt, "24") @@ -133,6 +258,34 @@ def underline(txt): def gray_bg(txt): return Decore.decorate("100", txt) + @staticmethod + def red_bg(txt): + return Decore.decorate("41", txt, "49") + + @staticmethod + def green_bg(txt): + return Decore.decorate("42", txt, "49") + + @staticmethod + def yellow_bg(txt): + return Decore.decorate("43", txt, "49") + + @staticmethod + def title(txt, len=None, bold=True): + """Print section header with horizontal bar line above it + 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 +324,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 +823,15 @@ def pr_proto_loopack(self, pipe=''): print(row) def pr_wifi_ssids(self): - hdr = (f"{'SSID':<{PadWifiScan.ssid}}" - f"{'ENCRYPTION':<{PadWifiScan.encryption}}" - f"{'SIGNAL':<{PadWifiScan.signal}}" - ) + hdr = (f"{'SSID':<{PadWifiScan.ssid}}" + f"{'ENCRYPTION':<{PadWifiScan.encryption}}" + f"{'SIGNAL':<{PadWifiScan.signal}}") print(Decore.invert(hdr)) - results=self.wifi.get("scan-results", {}) + results = self.wifi.get("scan-results", {}) for result in results: - encstr = ",".join(result["encryption"]) - status=rssi_to_status(result["rssi"]) + encstr = ", ".join(result["encryption"]) + status = rssi_to_status(result["rssi"]) row = f"{result['ssid']:<{PadWifiScan.ssid}}" row += f"{encstr:<{PadWifiScan.encryption}}" row += f"{status:<{PadWifiScan.signal}}" @@ -1329,13 +1499,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 +1586,675 @@ def show_lldp(json): entry.print() +def parse_firewall_log_line(line): + """Parse a single firewall log line into structured data""" + + # Look for kernel logs with netfilter IN=/OUT= fields + if not ('kernel' in line and 'IN=' in line and 'OUT=' in line): + return None + + # Extract timestamp from syslog format: Aug 17 12:34:56 + timestamp_match = re.match(r'^(\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})', line.strip()) + if not timestamp_match: + return None + + timestamp = timestamp_match.group(1) + + # Look for action indicator in the log line + action = 'DROP' + if 'REJECT' in line: + action = 'REJECT' + + # Extract key fields from netfilter log + patterns = { + 'in_iface': r'IN=([^\s]*)', + 'out_iface': r'OUT=([^\s]*)', + 'src': r'SRC=([^\s]+)', + 'dst': r'DST=([^\s]+)', + 'proto': r'PROTO=([^\s]+)', + 'spt': r'SPT=([^\s]+)', + 'dpt': r'DPT=([^\s]+)', + } + + parsed = {'timestamp': timestamp, 'action': action} + + for key, pattern in patterns.items(): + match = re.search(pattern, line) + value = match.group(1) if match else '' + + # Compress any IPv6 addresses for src and dst + if key in ['src', 'dst'] and value: + try: + ip = ipaddress.ip_address(value) + if isinstance(ip, ipaddress.IPv6Address): + value = str(ip.compressed) + except ValueError: + # Not a valid IP address, keep original value + pass + + parsed[key] = value + + return parsed + + +def show_firewall_logs(limit=10): + """Show recent firewall log entries, tail -N equivalent""" + try: + hdr = (f"{'TIME':<{PadFirewall.log_time}} " + f"{'ACTION':<{PadFirewall.log_action}} " + f"{'SOURCE':<{PadFirewall.log_src}} " + f"{'DEST':<{PadFirewall.log_dst}} " + f"{'PROTO':<{PadFirewall.log_proto}} " + f"{'PORT':>{PadFirewall.log_port}}") + + Decore.title("Recent Firewall Logs", len(hdr)) + + with open('/var/log/firewall.log', 'r', encoding='utf-8') as f: + lines = deque(f, maxlen=limit) + + if not lines: + raise FileNotFoundError + + print(Decore.invert(hdr)) + for line in lines: + parsed = parse_firewall_log_line(line) + if not parsed: + continue + + time_str = '' + if parsed['timestamp']: + try: + ts = parsed['timestamp'].strip() + if 'T' in ts: # ISO format + dt = datetime.fromisoformat(ts) + time_str = dt.strftime("%b %d %H:%M:%S") + else: # syslog format + dt = datetime.strptime(ts, "%b %d %H:%M:%S") + time_str = dt.strftime("%b %d %H:%M:%S") + except Exception: + time_str = parsed['timestamp'][:PadFirewall.log_time-1] + + if parsed['action'] == 'REJECT': + action_color = Decore.red + else: + action_color = Decore.yellow + action = action_color(parsed['action']) + + print(f"{time_str:<{PadFirewall.log_time}} " + f"{action:<{PadFirewall.log_action + 10}} " + f"{parsed['src']:<{PadFirewall.log_src}} " + f"{parsed['dst']:<{PadFirewall.log_dst}} " + f"{parsed['proto']:<{PadFirewall.log_proto}} " + f"{parsed['dpt']:>{PadFirewall.log_port}}") + + except FileNotFoundError: + print("No logs found (may be disabled or no denied traffic)") + except Exception as e: + print(f"Error reading firewall logs: {e}") + + +def show_firewall(json): + """Show firewall overview with matrix and tables""" + fw = json.get('infix-firewall:firewall', {}) + 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 ip_in_network(ip_addr, network): + """Check if an IP address falls within a CIDR network""" + try: + ip = ipaddress.ip_address(ip_addr) + net = ipaddress.ip_network(network, strict=False) + return ip in net + except: + return False + + +def pfw_cond(zones): + """Check for port-forwards that target IPs within zone networks""" + cond = [] + + # Collect all port-forwards with destinations + pfwd = [] + for zone in zones: + zone_name = zone.get('name', '') + for pf in zone.get('port-forward', []): + to = pf.get('to', {}) + if 'addr' in to: + lower = pf.get('lower') + upper = pf.get('upper') + pfwd.append((zone_name, to['addr'], lower, upper)) + + # Check each destination against all zone networks + for zone in zones: + zone_name = zone.get('name', '') + for network in zone.get('network', []): + for pf_zone, dest_ip, lower, upper in pfwd: + if ip_in_network(dest_ip, network): + if upper: + port_desc = f"ports {lower}-{upper}" + else: + port_desc = f"port {lower}" + cond.append(f"Port-forward in {pf_zone} ({port_desc}) targets {dest_ip} in {zone_name} network {network}") + return cond + + +def build_policy_map(policies): + """Build enhanced policy lookup with conditional detection""" + policy_map = {} + + for policy in policies: + ingress_zones = policy.get('ingress', []) + egress_zones = policy.get('egress', []) + services = policy.get('service', []) + action = policy.get('action', 'reject') + policy_name = policy.get('name', 'unknown') + + for ing in ingress_zones: + for egr in egress_zones: + if ing != 'ANY' and egr != 'ANY': + key = (ing, egr) + + if key not in policy_map: + policy_map[key] = { + 'allow': False, + 'conditional': False, + 'services': set(), + 'policies': [] + } + + if action in ['accept', 'continue']: + policy_map[key]['allow'] = True + + if services: + policy_map[key]['conditional'] = True + policy_map[key]['services'].update(services) + + policy_map[key]['policies'].append(policy_name) + return policy_map + + +def traffic_flow(from_zone, to_zone, policy_map, zones, cell_width): + """Apply heuristics to deterime traffic flows between zones + Args: + from_zone, to_zone : Names and direction of flow + policy_map : Map of all policies + zones : All the zone data + cell_width : Table cell width for pretty printing + + Returns: the symbol to show for zone-to-zone traffic + """ + def make_cell(symbol, bg_func): + # Create full-width colored cell + return bg_func(f" {symbol:^{cell_width}} ") + + # Handle HOST zone specially + if from_zone == "HOST" and to_zone == "HOST": + # HOST-to-HOST communication (localhost) - not applicable + return make_cell("—", Decore.gray_bg) + + # HOST-to-zone traffic: firewall input rules control this + if from_zone == "HOST": + # Traffic from firewall device to zones - typically allowed + return make_cell("✓", Decore.green_bg) + + # zone-to-HOST traffic: firewall input rules control this + if to_zone == "HOST": + # Traffic to HOST depends on zone's input action and services + zone = next((z for z in zones if z.get('name') == from_zone), None) + if zone: + action = zone.get('action', 'reject') + services = zone.get('service', []) + + if action == 'accept': + # Zone allows any input to HOST + return make_cell("✓", Decore.green_bg) + if services: + # Zone has specific services allowed to HOST + return make_cell("⚠", Decore.yellow_bg) + + # Zone has drop/reject action and no services + return make_cell("✗", Decore.red_bg) + + return make_cell("✗", Decore.red_bg) + + # Check if forwarding is enabled for same-zone communication + if from_zone == to_zone: + zone = next((z for z in zones if z.get('name') == from_zone), None) + if zone and zone.get('forwarding'): + return make_cell("✓", Decore.green_bg) + return make_cell("✗", Decore.red_bg) + + key = (from_zone, to_zone) + policy = policy_map.get(key) + + # Check for port-forward network overlap conditionals + cond = pfw_cond(zones) + pfwd_overlap = any(from_zone in cond or to_zone in cond for cond in cond) + + if (policy and policy['conditional']) or pfwd_overlap: + return make_cell("⚠", Decore.yellow_bg) + + if not policy or not policy['allow']: + # Check if from_zone has port forwarding rules (makes it conditional) + zone = next((z for z in zones if z.get('name') == from_zone), None) + if zone: + action = zone.get('action', 'reject') + pfwd = zone.get('port-forward', []) + if action in ['reject', 'drop'] and pfwd: + # Some traffic allowed via port forwarding + return make_cell("⚠", Decore.yellow_bg) + + # Check for port-forward network overlap + if pfwd_overlap: + return make_cell("⚠", Decore.yellow_bg) + + return make_cell("✗", Decore.red_bg) + + return make_cell("✓", Decore.green_bg) + + +def show_firewall_matrix(fw): + """Show zone-to-zone traffic matrix""" + zones = fw.get('zone', []) + policies = fw.get('policy', []) + + zone_names = [z['name'] for z in zones if z.get('interface')] + + # Always add the implicit HOST zone + zone_names.insert(0, "HOST") + + if len(zone_names) <= 1: + return None + + # 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, 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): + """Show firewall zones table or specific zone details""" + fw = json.get('infix-firewall:firewall', {}) + zones = fw.get('zone', []) + policies = fw.get('policy', []) + + if zone_name: + zone = next((z for z in zones if z.get('name') == zone_name), None) + if not zone: + print(f"Zone '{zone_name}' not found") + return + + description = zone.get('description', '') + interfaces = zone.get('interface', []) + if not interfaces: + interfaces = "" + networks = zone.get('network', []) + if not networks: + networks = "" + services = zone.get('service', []) + if not services: + services = "" + action = zone.get('action', 'reject') + if action == 'accept': + services_display = "(any)" + else: + services_display = ", ".join(services) if services else "(none)" + forwarding = "yes" if zone.get('forwarding') else "no" + + print(format_description('description', description)) + print(f"{'name':<20}: {zone_name}") + print(f"{'action':<20}: {action}") + print(f"{'interfaces':<20}: {compress_interface_list(interfaces)}") + print(f"{'networks':<20}: {', '.join(networks)}") + print(f"{'forwarding':<20}: {forwarding}") + print(f"{'services':<20}: {services_display}") + + port_forwards = zone.get('port-forward', []) + if port_forwards: + hdr = (f"{'FROM':<{PadFirewall.zone_pfwd_from}}" + f"{'TO':<{PadFirewall.zone_pfwd_to}}") + Decore.title(f"Port Forwards From {zone_name}", len(hdr)) + print(Decore.invert(hdr)) + + 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 = fwd.get('to', {}) + to_port = to.get('port', lower) + + if upper: + range_size = upper - lower + to_upper = to_port + range_size + to_str = f"{to.get('addr', '')}:{to_port}-{to_upper}" + else: + to_str = f"{to.get('addr', '')}:{to_port}" + + print(f"{from_port:<{PadFirewall.zone_pfwd_from}}" + f"{to_str:<{PadFirewall.zone_pfwd_to}}") + + 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 From {zone_name} →", len(hdr)) + print(Decore.invert(hdr)) + + # Add HOST zone first + current_zone = next((z for z in zones if z.get('name') == zone_name), None) + if current_zone: + # Zone-to-HOST traffic logic + action = current_zone.get('action', 'reject') + services = current_zone.get('service', []) + + if action == 'accept': + host_action = "✓ allow" + host_services = "(any)" + elif services: + host_action = "⚠ conditional" + host_services = ", ".join(services) + else: + host_action = "✗ deny" + host_services = "(none)" + + print(f"{'HOST':<{PadFirewall.zone_flow_to}}" + f"{host_action:<{PadFirewall.zone_flow_action}}" + f"{'(services)':<{PadFirewall.zone_flow_policy}}" + f"{host_services}") + + # Add other zones + for other_zone in zones: + if other_zone.get('name') == zone_name: + continue + + # 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"{'ACTION':<{PadFirewall.zone_action}}" + f"{'INTERFACES':<{PadFirewall.zone_interfaces}}" + f"{'SERVICES':<{PadFirewall.zone_services}}") + Decore.title("Zones", len(hdr)) + print(Decore.invert(hdr)) + for zone in zones: + name = zone.get('name', '') + action = zone.get('action', 'reject') + interface_list = zone.get('interface', []) + if not interface_list: + interfaces = "(none)" + else: + interfaces = compress_interface_list(interface_list) + services = ", ".join(zone.get('service', [])) + if not services: + if action == "accept": + services = "(any)" + else: + services = "(none)" + + immutable = zone.get('immutable', False) + locked = "⚷" if immutable else " " + + print(f"{locked:<{PadFirewall.zone_locked}}" + f"{name:<{PadFirewall.zone_name}}" + f"{action:<{PadFirewall.zone_action}}" + f"{interfaces:<{PadFirewall.zone_interfaces}}" + f"{services}") + + +def show_firewall_policy(json, policy_name=None): + """Show firewall policies table or specific policy details""" + fw = json.get('infix-firewall:firewall', {}) + policies = fw.get('policy', []) + + if policy_name: + policy = next((p for p in policies if p.get('name') == policy_name), None) + if not policy: + print(f"Policy '{policy_name}' not found") + return + + ingress = policy.get('ingress', []) + egress = policy.get('egress', []) + 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 +2287,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 +2318,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 bf69178f8..172f9de16 100644 --- a/src/statd/python/yanger/__main__.py +++ b/src/statd/python/yanger/__main__.py @@ -78,6 +78,9 @@ def dirpath(path): elif args.model == 'ieee802-dot1ab-lldp': from . import infix_lldp yang_data = infix_lldp.operational() + elif args.model == 'infix-firewall': + from . import infix_firewall + yang_data = infix_firewall.operational() else: common.LOG.warning("Unsupported model %s", args.model) sys.exit(1) diff --git a/src/statd/python/yanger/infix_firewall.py b/src/statd/python/yanger/infix_firewall.py new file mode 100644 index 000000000..72fd9c302 --- /dev/null +++ b/src/statd/python/yanger/infix_firewall.py @@ -0,0 +1,341 @@ +#!/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) + zone = { + "name": name, + "action": "accept", + "interface": list(settings.get('interfaces', [])), + "network": list(settings.get('sources', [])), + "service": list(settings.get('services', [])), + "port-forward": [], + "forwarding": False + } + + target = settings.get('target', 'default') + action = { + "%%REJECT%%": "reject", + "REJECT": "reject", + "ACCEPT": "accept", + "DROP": "drop", + "default": "accept" + } + zone["action"] = action.get(target, "accept") + zone["forwarding"] = bool(settings.get('forward', 0)) + zone["description"] = settings.get('description', 0) + + short = settings.get('short', '') + zone["immutable"] = bool(short and "(immutable)" in short) + + forwards = settings.get('forward_ports', []) + for fwd in forwards: + try: + 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'] + + zone["port-forward"].append(fwd_data) + + except (ValueError, TypeError) as e: + common.LOG.warning("Skipping invalid port forward rule in zone %s: %s (data: %s)", + name, e, fwd) + return zone + + except Exception as e: + 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: + # Override interfaces and networks with active zone data to ensure accuracy + 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) + + 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 f69894b45..dcd8738c3 100644 --- a/src/statd/statd.c +++ b/src/statd/statd.c @@ -43,6 +43,7 @@ #define XPATH_CONTAIN_BASE "/infix-containers:containers" #define XPATH_DHCP_SERVER_BASE "/infix-dhcp-server:dhcp-server" #define XPATH_LLDP_BASE "/ieee802-dot1ab-lldp:lldp" +#define XPATH_FIREWALL_BASE "/infix-firewall:firewall" TAILQ_HEAD(sub_head, sub); @@ -356,6 +357,8 @@ static int subscribe_to_all(struct statd *statd) #endif if (subscribe(statd, "infix-dhcp-server", XPATH_DHCP_SERVER_BASE, sr_generic_cb)) return SR_ERR_INTERNAL; + if (subscribe(statd, "infix-firewall", XPATH_FIREWALL_BASE, sr_generic_cb)) + return SR_ERR_INTERNAL; INFO("Successfully subscribed to all models"); return SR_ERR_OK; diff --git a/test/.env b/test/.env index 09bf096fb..962afdf37 100644 --- a/test/.env +++ b/test/.env @@ -2,7 +2,7 @@ # shellcheck disable=SC2034,SC2154 # Current container image -INFIX_TEST=ghcr.io/kernelkit/infix-test:2.4 +INFIX_TEST=ghcr.io/kernelkit/infix-test:2.5 ixdir=$(readlink -f "$testdir/..") logdir=$(readlink -f "$testdir/.log") diff --git a/test/case/all.yaml b/test/case/all.yaml index 3db38337e..2110283bd 100644 --- a/test/case/all.yaml +++ b/test/case/all.yaml @@ -35,5 +35,8 @@ - name: infix-services suite: infix_services/infix_services.yaml +- name: infix-firewall + suite: infix_firewall/infix_firewall.yaml + - name: use-cases suite: use_case/use_case.yml diff --git a/test/case/infix_firewall/Readme.adoc b/test/case/infix_firewall/Readme.adoc new file mode 100644 index 000000000..dd26ef29e --- /dev/null +++ b/test/case/infix_firewall/Readme.adoc @@ -0,0 +1,10 @@ +:testgroup: +== infix-firewall + +<<< + +include::basic/test.py[] + +include::lan-wan/test.py[] + +include::wan-dmz-lan/test.py[] diff --git a/test/case/infix_firewall/basic/Readme.adoc b/test/case/infix_firewall/basic/Readme.adoc new file mode 120000 index 000000000..885dcc595 --- /dev/null +++ b/test/case/infix_firewall/basic/Readme.adoc @@ -0,0 +1 @@ +basic.adoc \ No newline at end of file diff --git a/test/case/infix_firewall/basic/basic.adoc b/test/case/infix_firewall/basic/basic.adoc new file mode 100644 index 000000000..dc77fcc41 --- /dev/null +++ b/test/case/infix_firewall/basic/basic.adoc @@ -0,0 +1,48 @@ +=== Basic firewall test for end devices +==== Description +Test a simple restrictive firewall configuration suitable for end devices +like laptops or phones on untrusted networks. + +The test verifies: +- Single zone configuration similar to firewalld's default "public" zone +- Zone "public" with action=drop for data interface +- Zone "mgmt" with action=accept for management interface (NETCONF/RESTCONF) +- Allowed services: SSH (port 22), DHCPv6-client +- Blocked: All other ports (HTTP, HTTPS, Telnet, etc.) + +Port scanning tests validate that: +- SSH access is allowed (for management) +- All non-essential services are blocked/filtered +- Firewall properly filters unwanted traffic + +Uses the infamy.PortScanner class with netcat to test port accessibility: +- Open: Port accessible (service running) +- Closed: Port not filtered but no service listening +- Filtered: Port blocked by firewall (timeout/drop) + +This provides suitable protection for end-device scenarios while +maintaining management access for testing. + +==== Topology +ifdef::topdoc[] +image::{topdoc}../../test/case/infix_firewall/basic/topology.svg[Basic firewall test for end devices topology] +endif::topdoc[] +ifndef::topdoc[] +ifdef::testgroup[] +image::basic/topology.svg[Basic firewall test for end devices topology] +endif::testgroup[] +ifndef::testgroup[] +image::topology.svg[Basic firewall test for end devices topology] +endif::testgroup[] +endif::topdoc[] +==== Test sequence +. Set up topology and attach to target +. Configure basic end-device firewall +. Verify ICMP is dropped +. Verify ICMPv6 is dropped +. Verify SSH port (22) is allowed +. Verify well-known ports are blocked except SSH + + +<<< + diff --git a/test/case/infix_firewall/basic/test.py b/test/case/infix_firewall/basic/test.py new file mode 100755 index 000000000..f698ce025 --- /dev/null +++ b/test/case/infix_firewall/basic/test.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +""" +Basic firewall test for end devices + +Test a simple restrictive firewall configuration suitable for end devices +like laptops or phones on untrusted networks. + +The test verifies: +- Single zone configuration similar to firewalld's default "public" zone +- Zone "public" with action=drop for data interface +- Zone "mgmt" with action=accept for management interface (NETCONF/RESTCONF) +- Allowed services: SSH (port 22), DHCPv6-client +- Blocked: All other ports (HTTP, HTTPS, Telnet, etc.) + +Port scanning tests validate that: +- SSH access is allowed (for management) +- All non-essential services are blocked/filtered +- Firewall properly filters unwanted traffic + +Uses the infamy.PortScanner class with netcat to test port accessibility: +- Open: Port accessible (service running) +- Closed: Port not filtered but no service listening +- Filtered: Port blocked by firewall (timeout/drop) + +This provides suitable protection for end-device scenarios while +maintaining management access for testing. +""" +from time import sleep +import infamy +from infamy.util import until + +# Some helper classes return extra debug info which this switch unlocks +DEBUG = False + + +def debug(msg): + """Debug messages not relevant for regular test output""" + if DEBUG: + print(msg) + +with infamy.Test() as test: + with test.step("Set up topology and attach to target"): + env = infamy.Env() + target = env.attach("target", "mgmt") + _, data_if = env.ltop.xlate("target", "data") + _, mgmt_if = env.ltop.xlate("target", "mgmt") + _, host_data = env.ltop.xlate("host", "data") + + # Get target IP for scanning + TARGET_IP = "192.168.1.1" + HOST_IP = "192.168.1.42" + + with test.step("Configure basic end-device firewall"): + target.put_config_dict("ietf-interfaces", { + "interfaces": { + "interface": [ + { + "name": data_if, + "enabled": True, + "ipv4": { + "address": [{ + "ip": TARGET_IP, + "prefix-length": 24 + }] + } + } + ] + } + }) + + # Configure firewall with management and public zones + target.put_config_dict("infix-firewall", { + "firewall": { + "default": "public", + "logging": "all", + "zone": [ + { + "name": "mgmt", + "description": "Management network - allow NETCONF/RESTCONF", + "action": "accept", + "interface": [mgmt_if], + "service": ["ssh", "netconf", "restconf"] + }, + { + "name": "public", + "description": "Public untrusted network - end device protection", + "action": "drop", + "interface": [data_if], + "service": ["ssh", "dhcpv6-client"] + } + ] + } + }) + + debug("Waiting for configuration to take ...") + sleep(1) + + # Verify firewall configuration + config = target.get_config_dict("/infix-firewall:firewall") + fw = config["firewall"] + + assert fw["default"] == "public" + assert len(fw["zone"]) == 2 + + # Check zones + zones = {z["name"]: z for z in fw["zone"]} + + # Verify management zone + mgmt_zone = zones["mgmt"] + assert mgmt_zone["action"] == "accept" + assert mgmt_if in mgmt_zone["interface"] + assert "ssh" in mgmt_zone["service"] + assert "netconf" in mgmt_zone["service"] + + # Verify public zone + public_zone = zones["public"] + assert public_zone["action"] == "drop" + assert data_if in public_zone["interface"] + assert "ssh" in public_zone["service"] + assert "dhcpv6-client" in public_zone["service"] + + with infamy.IsolatedMacVlan(host_data) as ns: + ns.addip(HOST_IP) + + with test.step("Verify ICMP is dropped"): + ns.must_not_reach(TARGET_IP, timeout=2) + + with test.step("Verify ICMPv6 is dropped"): + ns.must_not_reach6("fe80::1%iface", timeout=2) + + with test.step("Verify SSH port (22) is allowed"): + scanner = infamy.PortScanner(ns) + ssh_result = scanner.scan_port(TARGET_IP, 22, timeout=2) + assert ssh_result["status"] in ["open", "closed"], \ + f"SSH port should be allowed, got: {ssh_result['status']}" + + with test.step("Verify well-known ports are blocked except SSH"): + firewall = infamy.Firewall(ns, None) + allowed = [22] # Only SSH should be allowed + + ok, open_ports, filtered_ports = \ + firewall.verify_blocked(TARGET_IP, exempt=allowed) + + for port in open_ports: + print(f" ⚠ Unexpectedly open: {port}") + for port in filtered_ports: + print(f" ⚠ SSH blocked when it should be allowed: {port}") + + if not ok: + if open_ports: + print(f"Found unexpected open ports: {', '.join(open_ports)}") + test.fail() + if filtered_ports: + print(f"SSH port blocked when it should be allowed: {', '.join(filtered_ports)}") + test.fail() + else: + debug(" ✓ Firewall policy working correctly - only SSH allowed") + + test.succeed() diff --git a/test/case/infix_firewall/basic/topology.dot b/test/case/infix_firewall/basic/topology.dot new file mode 100644 index 000000000..6cc760fb9 --- /dev/null +++ b/test/case/infix_firewall/basic/topology.dot @@ -0,0 +1,23 @@ +graph "1x2" { + layout = "neato"; + overlap = false; + esep = "+80"; + + node [shape=record, fontname="DejaVu Sans Mono, Book"]; + edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"]; + + host [ + label="host | { mgmt | data }", + pos="1,1!", + requires="controller" + ]; + + target [ + label="{ mgmt | data } | target", + pos="3,1!", + requires="infix", + ]; + + host:mgmt -- target:mgmt [requires="mgmt", color="lightgray"] + host:data -- target:data [color=black, fontcolor=black, taillabel="192.168.1.42/24"] +} diff --git a/test/case/infix_firewall/basic/topology.svg b/test/case/infix_firewall/basic/topology.svg new file mode 100644 index 000000000..97adf98a3 --- /dev/null +++ b/test/case/infix_firewall/basic/topology.svg @@ -0,0 +1,43 @@ + + + + + + +1x2 + + + +host + +host + +mgmt + +data + + + +target + +mgmt + +data + +target + + + +host:mgmt--target:mgmt + + + + +host:data--target:data + +192.168.1.42/24 + + + diff --git a/test/case/infix_firewall/infix_firewall.yaml b/test/case/infix_firewall/infix_firewall.yaml new file mode 100644 index 000000000..5438c9ccd --- /dev/null +++ b/test/case/infix_firewall/infix_firewall.yaml @@ -0,0 +1,7 @@ +--- +- name: basic + case: basic/test.py +- name: lan-to-wan + case: lan-wan/test.py +- name: wan-dmz-lan + case: wan-dmz-lan/test.py diff --git a/test/case/infix_firewall/lan-wan/Readme.adoc b/test/case/infix_firewall/lan-wan/Readme.adoc new file mode 120000 index 000000000..ec6162e27 --- /dev/null +++ b/test/case/infix_firewall/lan-wan/Readme.adoc @@ -0,0 +1 @@ +lan-to-wan.adoc \ No newline at end of file diff --git a/test/case/infix_firewall/lan-wan/lan-to-wan.adoc b/test/case/infix_firewall/lan-wan/lan-to-wan.adoc new file mode 100644 index 000000000..bfe1d2346 --- /dev/null +++ b/test/case/infix_firewall/lan-wan/lan-to-wan.adoc @@ -0,0 +1,49 @@ +=== LAN-WAN firewall test with masquerading +==== Description +Test a typical home/office router scenario where the target device acts as +a gateway with LAN-to-WAN traffic forwarding and masquerading (SNAT). + +Architecture: +- Target device = Gateway with firewall and NAT +- Test host has two interfaces: one LAN-side, one WAN-side (Internet) +- Test host's LAN interface acts as a client behind the router +- Test host's WAN interface acts as an Internet server/destination + +The test verifies: +- LAN zone with action=accept for internal traffic +- WAN zone with action=drop for external interface +- Policy to allow LAN-to-WAN forwarding with masquerading +- Interface forwarding enabled on router +- Outbound connectivity from LAN clients through WAN works +- Inbound unsolicited traffic from WAN is blocked +- Masquerading (source NAT) functions correctly + +This validates a typical home router protecting internal network while +allowing outbound internet access. + +==== Topology +ifdef::topdoc[] +image::{topdoc}../../test/case/infix_firewall/lan-wan/topology.svg[LAN-WAN firewall test with masquerading topology] +endif::topdoc[] +ifndef::topdoc[] +ifdef::testgroup[] +image::lan-wan/topology.svg[LAN-WAN firewall test with masquerading topology] +endif::testgroup[] +ifndef::testgroup[] +image::topology.svg[LAN-WAN firewall test with masquerading topology] +endif::testgroup[] +endif::topdoc[] +==== Test sequence +. Set up topology and attach to gateway +. Configure gateway with firewall and SNAT +. Verify LAN access to router +. Verify WAN access to router is blocked +. Verify LAN services accessibility +. Verify WAN blocks all well-known ports +. Verify LAN-to-WAN connectivity (outbound) +. Verify WAN-to-LAN blocking (inbound) +. Verify LAN-to-WAN masquerading + + +<<< + diff --git a/test/case/infix_firewall/lan-wan/test.py b/test/case/infix_firewall/lan-wan/test.py new file mode 100755 index 000000000..93ca54eea --- /dev/null +++ b/test/case/infix_firewall/lan-wan/test.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +""" +LAN-WAN firewall test with masquerading + +Test a typical home/office router scenario where the target device acts as +a gateway with LAN-to-WAN traffic forwarding and masquerading (SNAT). + +Architecture: +- Target device = Gateway with firewall and NAT +- Test host has two interfaces: one LAN-side, one WAN-side (Internet) +- Test host's LAN interface acts as a client behind the router +- Test host's WAN interface acts as an Internet server/destination + +The test verifies: +- LAN zone with action=accept for internal traffic +- WAN zone with action=drop for external interface +- Policy to allow LAN-to-WAN forwarding with masquerading +- Interface forwarding enabled on router +- Outbound connectivity from LAN clients through WAN works +- Inbound unsolicited traffic from WAN is blocked +- Masquerading (source NAT) functions correctly + +This validates a typical home router protecting internal network while +allowing outbound internet access. +""" +from time import sleep +import infamy +from infamy.util import until + +# Some helper classes return extra debug info which this switch unlocks +DEBUG = False + + +def debug(msg): + """Debug messages not relevant for regular test output""" + if DEBUG: + print(msg) + + +with infamy.Test() as test: + with test.step("Set up topology and attach to gateway"): + env = infamy.Env() + gateway = env.attach("gateway", "mgmt") + _, lan_if = env.ltop.xlate("gateway", "lan") + _, wan_if = env.ltop.xlate("gateway", "wan") + _, mgmt_if = env.ltop.xlate("gateway", "mgmt") + _, host_lan = env.ltop.xlate("host", "lan") # Host LAN-side interface + _, host_wan = env.ltop.xlate("host", "wan") # Host WAN-side interface + + LAN_NET = "192.168.1.0/24" + LAN_ROUTER_IP = "192.168.1.1" # Router's LAN interface + LAN_CLIENT_IP = "192.168.1.100" # Client on LAN side + + WAN_NET = "203.0.113.0/24" # RFC 5737 test network + WAN_ROUTER_IP = "203.0.113.1" # Router's WAN interface + WAN_SERVER_IP = "203.0.113.100" # Server on WAN side + + with test.step("Configure gateway with firewall and SNAT"): + gateway.put_config_dict("ietf-interfaces", { + "interfaces": { + "interface": [ + { + "name": lan_if, + "enabled": True, + "ipv4": { + "forwarding": True, + "address": [{ + "ip": LAN_ROUTER_IP, + "prefix-length": 24 + }] + } + }, + { + "name": wan_if, + "enabled": True, + "ipv4": { + "forwarding": True, + "address": [{ + "ip": WAN_ROUTER_IP, + "prefix-length": 24 + }] + } + } + ] + } + }) + + gateway.put_config_dict("infix-firewall", { + "firewall": { + "default": "wan", + "logging": "all", + "zone": [ + { + "name": "lan", + "description": "Internal LAN network - trusted", + "action": "accept", + "interface": [lan_if, mgmt_if], + "service": ["ssh", "dhcp", "dns"] + }, { + "name": "wan", + "description": "External WAN interface - untrusted", + "action": "drop", + "interface": [wan_if] + } + ], + "policy": [ + { + "name": "lan-to-wan", + "description": "Allow LAN to WAN traffic with SNAT", + "ingress": ["lan"], + "egress": ["wan"], + "action": "accept", + "masquerade": True + } + ] + } + }) + + debug("Waiting for router configuration to take effect...") + sleep(1) + + # Verify firewall configuration + config = gateway.get_config_dict("/infix-firewall:firewall") + fw = config["firewall"] + + assert fw["default"] == "wan" + assert len(fw["zone"]) == 2 + + # Check zones + zones = {z["name"]: z for z in fw["zone"]} + + # Verify LAN zone + lan_zone = zones["lan"] + assert lan_zone["action"] == "accept" + assert lan_if in lan_zone["interface"] + + # Verify WAN zone + wan_zone = zones["wan"] + assert wan_zone["action"] == "drop" + assert wan_if in wan_zone["interface"] + + # Verify policy + policies = {p["name"]: p for p in fw["policy"]} + lan_wan_policy = policies["lan-to-wan"] + assert lan_wan_policy["ingress"] == ["lan"] + assert lan_wan_policy["egress"] == ["wan"] + assert lan_wan_policy["action"] == "accept" + assert lan_wan_policy["masquerade"] is True + + with infamy.IsolatedMacVlan(host_lan) as lan_client: + lan_client.addip(LAN_CLIENT_IP) + lan_client.addroute("0.0.0.0", LAN_ROUTER_IP, prefix_length="0") + + with infamy.IsolatedMacVlan(host_wan) as wan_server: + wan_server.addip(WAN_SERVER_IP) + + with test.step("Verify LAN access to router"): + lan_client.must_reach(LAN_ROUTER_IP, timeout=3) + + with test.step("Verify WAN access to router is blocked"): + wan_server.must_not_reach(WAN_ROUTER_IP, timeout=3) + + with test.step("Verify LAN services accessibility"): + firewall = infamy.Firewall(lan_client, None) + # Services expected to be available on LAN + svc = [ + (22, "tcp", "ssh"), + (53, "udp", "dns"), + (67, "udp", "dhcp"), + ] + + ok, ports = firewall.verify_allowed(LAN_ROUTER_IP, svc) + if ok: + debug(" ✓ All LAN services are accessible") + else: + print(f" ⚠ Some LAN services are filtered: {', '.join(ports)}") + test.fail() + + with test.step("Verify WAN blocks all well-known ports"): + firewall = infamy.Firewall(wan_server, None) + + ok, ports, _ = firewall.verify_blocked(WAN_ROUTER_IP) + if ok: + debug(" ✓ All well-known ports are blocked from WAN") + else: + print(f" ⚠ Some ports are unexpectedly open from WAN: {', '.join(ports)}") + test.fail() + + with test.step("Verify LAN-to-WAN connectivity (outbound)"): + lan_client.must_reach(WAN_SERVER_IP, timeout=3) + + with test.step("Verify WAN-to-LAN blocking (inbound)"): + wan_server.must_not_reach(LAN_CLIENT_IP, timeout=3) + + with test.step("Verify LAN-to-WAN masquerading"): + firewall = infamy.Firewall(lan_client, wan_server) + ok, info = firewall.verify_snat(WAN_SERVER_IP, WAN_ROUTER_IP) + if ok: + debug(f" ✓ {info}") + else: + print(f" ⚠ {info}") + test.fail() + + test.succeed() diff --git a/test/case/infix_firewall/lan-wan/topology.dot b/test/case/infix_firewall/lan-wan/topology.dot new file mode 100644 index 000000000..064446923 --- /dev/null +++ b/test/case/infix_firewall/lan-wan/topology.dot @@ -0,0 +1,24 @@ +graph "1x3" { + layout = "neato"; + overlap = false; + esep = "+80"; + + node [shape=record, fontname="DejaVu Sans Mono, Book"]; + edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"]; + + host [ + label="host | { mgmt | lan | wan }", + pos="1,1!", + requires="controller" + ]; + + gateway [ + label="{ mgmt | lan | wan } | gateway", + pos="3,1!", + requires="infix", + ]; + + host:mgmt -- gateway:mgmt [requires="mgmt", color="lightgray"] + host:lan -- gateway:lan [color=black, fontcolor=black, taillabel="192.168.1.0/24"] + host:wan -- gateway:wan [color=red, fontcolor=red, taillabel="203.0.113.0/24"] +} diff --git a/test/case/infix_firewall/lan-wan/topology.svg b/test/case/infix_firewall/lan-wan/topology.svg new file mode 100644 index 000000000..e5d25cfaf --- /dev/null +++ b/test/case/infix_firewall/lan-wan/topology.svg @@ -0,0 +1,53 @@ + + + + + + +1x3 + + + +host + +host + +mgmt + +lan + +wan + + + +gateway + +mgmt + +lan + +wan + +gateway + + + +host:mgmt--gateway:mgmt + + + + +host:lan--gateway:lan + +192.168.1.0/24 + + + +host:wan--gateway:wan + +203.0.113.0/24 + + + diff --git a/test/case/infix_firewall/wan-dmz-lan/Readme.adoc b/test/case/infix_firewall/wan-dmz-lan/Readme.adoc new file mode 120000 index 000000000..358ebc4cd --- /dev/null +++ b/test/case/infix_firewall/wan-dmz-lan/Readme.adoc @@ -0,0 +1 @@ +wan-dmz-lan.adoc \ No newline at end of file diff --git a/test/case/infix_firewall/wan-dmz-lan/test.py b/test/case/infix_firewall/wan-dmz-lan/test.py new file mode 100755 index 000000000..f68a77c9c --- /dev/null +++ b/test/case/infix_firewall/wan-dmz-lan/test.py @@ -0,0 +1,335 @@ +#!/usr/bin/env python3 +""" +WAN-DMZ-LAN Firewall with Port Forwarding + +Verifies a comprehensive multi-zone firewall setup with port forwarding (DNAT) +and masquerading (SNAT). + +Architecture: +- Target device = Gateway with WAN/DMZ/LAN zones and NAT +- Test host has four interfaces: WAN (Internet), DMZ (server zone), + LAN (internal), mgmt +- Host WAN interface acts as external Internet client +- Host DMZ interface acts as internal server (HTTP on port 80) +- Host LAN interface acts as internal LAN client + +The test verifies: +- WAN zone with action=drop for external interface +- DMZ zone with limited services (HTTP only) +- LAN zone with action=accept for internal network +- Port forwarding: WAN:8080 → DMZ:80 (DNAT) +- Policy loc-to-wan: DMZ+LAN → WAN with masquerading (SNAT) +- Policy lan-to-dmz: LAN → DMZ for SSH and HTTP access +- Proper zone isolation and access control +- End-to-end DNAT and SNAT functionality + +This validates complex firewall scenarios with both ingress NAT (DNAT) +and egress NAT (SNAT) plus comprehensive multi-zone policies. +""" +from time import sleep +import infamy +from infamy.util import until + +# Some helper classes return extra debug info which this switch unlocks +DEBUG = False + + +def debug(msg): + """Debug messages not relevant for regular test output""" + if DEBUG: + print(msg) + + +with infamy.Test() as test: + with test.step("Set up topology and attach to gateway"): + env = infamy.Env() + gateway = env.attach("gateway", "mgmt") + _, wan_if = env.ltop.xlate("gateway", "wan") + _, dmz_if = env.ltop.xlate("gateway", "dmz") + _, lan_if = env.ltop.xlate("gateway", "lan") + _, mgmt_if = env.ltop.xlate("gateway", "mgmt") + _, host_wan = env.ltop.xlate("host", "wan") + _, host_dmz = env.ltop.xlate("host", "dmz") + _, host_lan = env.ltop.xlate("host", "lan") + + WAN_NET = "203.0.113.0/24" # RFC 5737 test network + WAN_ROUTER_IP = "203.0.113.1" # Gateway WAN interface + WAN_CLIENT_IP = "203.0.113.100" # Host WAN interface + + DMZ_NET = "10.0.1.0/24" + DMZ_ROUTER_IP = "10.0.1.1" # Gateway DMZ interface + DMZ_SERVER_IP = "10.0.1.100" # Host DMZ interface + + LAN_NET = "192.168.1.0/24" + LAN_ROUTER_IP = "192.168.1.1" # Gateway LAN interface + LAN_CLIENT_IP = "192.168.1.100" # Host LAN interface + + with test.step("Configure gateway with multi-zone firewall and NAT"): + gateway.put_config_dict("ietf-interfaces", { + "interfaces": { + "interface": [ + { + "name": wan_if, + "enabled": True, + "ipv4": { + "forwarding": True, + "address": [{ + "ip": WAN_ROUTER_IP, + "prefix-length": 24 + }] + } + }, + { + "name": dmz_if, + "enabled": True, + "ipv4": { + "forwarding": True, + "address": [{ + "ip": DMZ_ROUTER_IP, + "prefix-length": 24 + }] + } + }, + { + "name": lan_if, + "enabled": True, + "ipv4": { + "forwarding": True, + "address": [{ + "ip": LAN_ROUTER_IP, + "prefix-length": 24 + }] + } + } + ] + } + }) + + gateway.put_config_dict("infix-firewall", { + "firewall": { + "default": "wan", + "logging": "all", + "zone": [ + { + "name": "wan", + "description": "External WAN interface - untrusted", + "action": "drop", + "interface": [wan_if], + "port-forward": [ + { + "lower": 8080, + "proto": "tcp", + "to": { + "addr": DMZ_SERVER_IP, + "port": 80 + } + } + ] + }, + { + "name": "dmz", + "description": "DMZ network - limited trust", + "action": "drop", + "interface": [dmz_if], + "service": ["http"] + }, + { + "name": "lan", + "description": "Internal LAN network - trusted", + "action": "accept", + "interface": [lan_if, mgmt_if], + "service": ["ssh", "dhcp", "dns"] + } + ], + "policy": [ + { + "name": "loc-to-wan", + "description": "Allow local networks to WAN with SNAT", + "ingress": ["lan", "dmz"], + "egress": ["wan"], + "action": "accept", + "masquerade": True + }, + { + "name": "lan-to-dmz", + "description": "Allow LAN access to DMZ services", + "ingress": ["lan"], + "egress": ["dmz"], + "action": "accept", + "service": ["ssh", "http"] + } + ] + } + }) + + debug("Waiting for gateway configuration to take effect...") + sleep(2) + + # Verify firewall configuration + config = gateway.get_config_dict("/infix-firewall:firewall") + fw = config["firewall"] + + assert fw["default"] == "wan" + assert len(fw["zone"]) == 3 + assert len(fw["policy"]) == 2 + + # Check zones + zones = {z["name"]: z for z in fw["zone"]} + + # Verify WAN zone with port forwarding + wan_zone = zones["wan"] + assert wan_zone["action"] == "drop" + assert wan_if in wan_zone["interface"] + assert len(wan_zone["port-forward"]) == 1 + # Access port-forward by iterating over the keyed list + pf = next(iter(wan_zone["port-forward"])) + assert pf["lower"] == 8080 + assert pf["to"]["addr"] == DMZ_SERVER_IP + assert pf["to"]["port"] == 80 + + # Verify DMZ zone + dmz_zone = zones["dmz"] + assert dmz_zone["action"] == "drop" + assert dmz_if in dmz_zone["interface"] + assert "http" in dmz_zone["service"] + + # Verify LAN zone + lan_zone = zones["lan"] + assert lan_zone["action"] == "accept" + assert lan_if in lan_zone["interface"] + + # Check policies + policies = {p["name"]: p for p in fw["policy"]} + + # Verify loc-to-wan policy + loc_wan_policy = policies["loc-to-wan"] + assert set(loc_wan_policy["ingress"]) == {"lan", "dmz"} + assert loc_wan_policy["egress"] == ["wan"] + assert loc_wan_policy["masquerade"] is True + + # Verify lan-to-dmz policy + lan_dmz_policy = policies["lan-to-dmz"] + assert lan_dmz_policy["ingress"] == ["lan"] + assert lan_dmz_policy["egress"] == ["dmz"] + assert "ssh" in lan_dmz_policy["service"] + assert "http" in lan_dmz_policy["service"] + + with infamy.IsolatedMacVlan(host_wan) as wan_client: + wan_client.addip(WAN_CLIENT_IP) + + with infamy.IsolatedMacVlan(host_dmz) as dmz_server: + dmz_server.addip(DMZ_SERVER_IP) + dmz_server.addroute("0.0.0.0", DMZ_ROUTER_IP, prefix_length="0") + + with infamy.IsolatedMacVlan(host_lan) as lan_client: + lan_client.addip(LAN_CLIENT_IP) + lan_client.addroute("0.0.0.0", LAN_ROUTER_IP, prefix_length="0") + + with test.step("Verify basic connectivity within zones"): + lan_client.must_reach(LAN_ROUTER_IP, timeout=3) + dmz_server.must_not_reach(DMZ_ROUTER_IP, timeout=3) + + with test.step("Verify WAN to DMZ port forwarding (DNAT)"): + firewall = infamy.Firewall(wan_client, dmz_server) + + # Test port forwarding: WAN:8080 → DMZ:80 + dnat_working, details = firewall.verify_dnat( + WAN_ROUTER_IP, forward_port=8080, target_port=80) + + if dnat_working: + debug(f" ✓ {details}") + else: + print(f" ⚠ {details}") + test.fail("DNAT port forwarding verification failed") + + with test.step("Verify LAN to DMZ connectivity"): + lan_client.must_reach(DMZ_SERVER_IP, timeout=3) + firewall = infamy.Firewall(lan_client, None) + svc = [ + (22, "tcp", "ssh"), + (80, "tcp", "http"), + ] + + ok, filtered = firewall.verify_allowed(DMZ_SERVER_IP, svc) + if ok: + debug(" ✓ LAN can access DMZ services (SSH, HTTP)") + else: + print(f" ⚠ Some DMZ services filtered from LAN: {', '.join(filtered)}") + + with test.step("Verify DMZ to LAN blocking"): + # DMZ should NOT be able to reach LAN (no policy exists) + dmz_server.must_not_reach(LAN_CLIENT_IP, timeout=3) + + with test.step("Verify WAN isolation"): + # WAN should NOT be able to reach LAN or DMZ directly + firewall = infamy.Firewall(wan_client, None) + + # Test LAN isolation from WAN + ok, open_ports, _ = firewall.verify_blocked(LAN_ROUTER_IP) + if ok: + debug(" ✓ LAN is isolated from WAN") + else: + print(f" ⚠ WAN can access LAN ports: {', '.join(open_ports)}") + test.fail("LAN not properly isolated from WAN") + + # Test DMZ isolation from WAN (except port forwarding) + ok, open_ports, _ = firewall.verify_blocked(DMZ_ROUTER_IP) + if ok: + debug(" ✓ DMZ is isolated from WAN") + else: + print(f" ⚠ WAN can access DMZ ports: {', '.join(open_ports)}") + + with test.step("Verify LAN to WAN connectivity with SNAT"): + # Test outbound connectivity + lan_client.must_reach(WAN_CLIENT_IP, timeout=3) + + # Verify SNAT masquerading + firewall = infamy.Firewall(lan_client, wan_client) + ok, info = firewall.verify_snat(WAN_CLIENT_IP, WAN_ROUTER_IP) + if ok: + debug(f" ✓ LAN to WAN SNAT: {info}") + else: + print(f" ⚠ LAN to WAN SNAT: {info}") + test.fail("LAN to WAN SNAT verification failed") + + with test.step("Verify DMZ to WAN connectivity with SNAT"): + # Test outbound connectivity + dmz_server.must_reach(WAN_CLIENT_IP, timeout=3) + + # Verify SNAT masquerading + firewall = infamy.Firewall(dmz_server, wan_client) + ok, info = firewall.verify_snat(WAN_CLIENT_IP, WAN_ROUTER_IP) + if ok: + debug(f" ✓ DMZ to WAN SNAT: {info}") + else: + print(f" ⚠ DMZ to WAN SNAT: {info}") + test.fail("DMZ to WAN SNAT verification failed") + + with test.step("Verify comprehensive zone policies"): + # Test that each zone has appropriate service accessibility + firewall_lan = infamy.Firewall(lan_client, None) + firewall_dmz = infamy.Firewall(dmz_server, None) + firewall_wan = infamy.Firewall(wan_client, None) + + # LAN zone should allow configured services + svc = [ + (22, "tcp", "ssh"), + (53, "udp", "dns"), + (67, "udp", "dhcp") + ] + ok, filtered = firewall_lan.verify_allowed(LAN_ROUTER_IP, svc) + if not ok: + print(f" ⚠ LAN services not properly accessible: {', '.join(filtered)}") + + # DMZ zone should allow only HTTP + svc = [(80, "tcp", "http")] + ok, filtered = firewall_dmz.verify_allowed(DMZ_ROUTER_IP, svc) + if not ok: + print(f" ⚠ DMZ HTTP service not accessible: {', '.join(filtered)}") + + # WAN zone should block all standard services + ok, open_ports, _ = firewall_wan.verify_blocked(WAN_ROUTER_IP) + if not ok: + print(f" ⚠ WAN has unexpected open ports: {', '.join(open_ports)}") + + test.succeed() diff --git a/test/case/infix_firewall/wan-dmz-lan/topology.dot b/test/case/infix_firewall/wan-dmz-lan/topology.dot new file mode 100644 index 000000000..c087f7b48 --- /dev/null +++ b/test/case/infix_firewall/wan-dmz-lan/topology.dot @@ -0,0 +1,25 @@ +graph "1x4" { + layout = "neato"; + overlap = false; + esep = "+80"; + + node [shape=record, fontname="DejaVu Sans Mono, Book"]; + edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"]; + + host [ + label="host | { mgmt | wan | dmz | lan }", + pos="1,1!", + requires="controller" + ]; + + gateway [ + label="{ mgmt | wan | dmz | lan } | gateway", + pos="3,1!", + requires="infix", + ]; + + host:mgmt -- gateway:mgmt [requires="mgmt", color="lightgray"] + host:wan -- gateway:wan [color=red, fontcolor=red, taillabel="203.0.113.0/24"] + host:dmz -- gateway:dmz [color=orange, fontcolor=orange, taillabel="10.0.1.0/24"] + host:lan -- gateway:lan [color=black, fontcolor=black, taillabel="192.168.1.0/24"] +} diff --git a/test/case/infix_firewall/wan-dmz-lan/topology.svg b/test/case/infix_firewall/wan-dmz-lan/topology.svg new file mode 100644 index 000000000..9e27552f6 --- /dev/null +++ b/test/case/infix_firewall/wan-dmz-lan/topology.svg @@ -0,0 +1,63 @@ + + + + + + +1x4 + + + +host + +host + +mgmt + +wan + +dmz + +lan + + + +gateway + +mgmt + +wan + +dmz + +lan + +gateway + + + +host:mgmt--gateway:mgmt + + + + +host:wan--gateway:wan + +203.0.113.0/24 + + + +host:dmz--gateway:dmz + +10.0.1.0/24 + + + +host:lan--gateway:lan + +192.168.1.0/24 + + + diff --git a/test/case/infix_firewall/wan-dmz-lan/wan-dmz-lan.adoc b/test/case/infix_firewall/wan-dmz-lan/wan-dmz-lan.adoc new file mode 100644 index 000000000..35aab19fa --- /dev/null +++ b/test/case/infix_firewall/wan-dmz-lan/wan-dmz-lan.adoc @@ -0,0 +1,53 @@ +=== WAN-DMZ-LAN Firewall with Port Forwarding +==== Description +Verifies a comprehensive multi-zone firewall setup with port forwarding (DNAT) +and masquerading (SNAT). + +Architecture: +- Target device = Gateway with WAN/DMZ/LAN zones and NAT +- Test host has four interfaces: WAN (Internet), DMZ (server zone), + LAN (internal), mgmt +- Host WAN interface acts as external Internet client +- Host DMZ interface acts as internal server (HTTP on port 80) +- Host LAN interface acts as internal LAN client + +The test verifies: +- WAN zone with action=drop for external interface +- DMZ zone with limited services (HTTP only) +- LAN zone with action=accept for internal network +- Port forwarding: WAN:8080 → DMZ:80 (DNAT) +- Policy loc-to-wan: DMZ+LAN → WAN with masquerading (SNAT) +- Policy lan-to-dmz: LAN → DMZ for SSH and HTTP access +- Proper zone isolation and access control +- End-to-end DNAT and SNAT functionality + +This validates complex firewall scenarios with both ingress NAT (DNAT) +and egress NAT (SNAT) plus comprehensive multi-zone policies. + +==== Topology +ifdef::topdoc[] +image::{topdoc}../../test/case/infix_firewall/wan-dmz-lan/topology.svg[WAN-DMZ-LAN Firewall with Port Forwarding topology] +endif::topdoc[] +ifndef::topdoc[] +ifdef::testgroup[] +image::wan-dmz-lan/topology.svg[WAN-DMZ-LAN Firewall with Port Forwarding topology] +endif::testgroup[] +ifndef::testgroup[] +image::topology.svg[WAN-DMZ-LAN Firewall with Port Forwarding topology] +endif::testgroup[] +endif::topdoc[] +==== Test sequence +. Set up topology and attach to gateway +. Configure gateway with multi-zone firewall and NAT +. Verify basic connectivity within zones +. Verify WAN to DMZ port forwarding (DNAT) +. Verify LAN to DMZ connectivity +. Verify DMZ to LAN blocking +. Verify WAN isolation +. Verify LAN to WAN connectivity with SNAT +. Verify DMZ to WAN connectivity with SNAT +. Verify comprehensive zone policies + + +<<< + diff --git a/test/case/repo/defconfig.sh b/test/case/repo/defconfig.sh index e8d0768a3..abd0f16cd 100755 --- a/test/case/repo/defconfig.sh +++ b/test/case/repo/defconfig.sh @@ -23,21 +23,25 @@ disabled_root_login() # For all defconfigs check() { - local total=$# - local num=1 - local base= + total=$# + num=1 + base= echo "1..$total" for defconfig in "$@"; do + # Skip UNIX backup files + case "$defconfig" in + *~|*.bak|'#'*'#'|.#*) + continue + ;; + esac base=$(basename "$defconfig") - if disabled_root_login "$defconfig"; then + if whitelist "$base"; then + echo "ok $num - $base is exempted # skip" + elif disabled_root_login "$defconfig"; then echo "ok $num - $base disables root logins" else - if whitelist "$base"; then - echo "ok $num - $base is exempted # skip" - else - echo "not ok $num - $base has not disabled root login" - fi + echo "not ok $num - $base has not disabled root login" fi num=$((num + 1)) done diff --git a/test/case/statd/system/cli/show-hardware b/test/case/statd/system/cli/show-hardware index a598d096e..10645d553 100644 --- a/test/case/statd/system/cli/show-hardware +++ b/test/case/statd/system/cli/show-hardware @@ -1,4 +1,5 @@ -USB PORTS  +────────────────────────────── +USB PORTS NAME STATE  USB locked USB2 locked diff --git a/test/docker/Dockerfile b/test/docker/Dockerfile index c9682dd0b..81a07e081 100644 --- a/test/docker/Dockerfile +++ b/test/docker/Dockerfile @@ -1,8 +1,12 @@ FROM alpine:3.18.0 +# NOTE: please add packages alphabetically! RUN apk add --no-cache \ busybox-extras \ + curl \ e2fsprogs \ + e2tools \ + ethtool \ fakeroot \ gcc \ git \ @@ -13,7 +17,10 @@ RUN apk add --no-cache \ libc-dev \ libyang-dev \ linux-headers \ + make \ + nmap \ openssh-client \ + openssl \ python3-dev \ qemu-img \ qemu-system-x86_64 \ @@ -22,12 +29,7 @@ RUN apk add --no-cache \ squashfs-tools \ sshpass \ tcpdump \ - tshark \ - openssl \ - curl \ - e2tools \ - make \ - ethtool + tshark ARG MTOOL_VERSION="3.0" RUN wget https://github.com/troglobit/mtools/releases/download/v3.0/mtools-$MTOOL_VERSION.tar.gz -O /tmp/mtools-$MTOOL_VERSION.tar.gz diff --git a/test/infamy/__init__.py b/test/infamy/__init__.py index 4c44ec8c3..f5397d93c 100644 --- a/test/infamy/__init__.py +++ b/test/infamy/__init__.py @@ -4,8 +4,10 @@ from .env import Env from .env import ArgumentParser from .env import test_argument +from .firewall import Firewall from .furl import Furl from .netns import IsolatedMacVlan,IsolatedMacVlans +from .portscanner import PortScanner from .sniffer import Sniffer from .tap import Test from .util import parallel, until diff --git a/test/infamy/firewall.py b/test/infamy/firewall.py new file mode 100644 index 000000000..04495de6b --- /dev/null +++ b/test/infamy/firewall.py @@ -0,0 +1,204 @@ +""" +Firewall testing utilities + +Provides a helper class and supporting tools to validate firewall +behavior in automated tests. Supports: + +- SNAT verification by inspecting captured ICMP traffic +- Zone policy checks using targeted port scans +- Positive and negative policy validation (allowed vs. blocked ports) +""" +import subprocess +import time +from typing import Tuple, List +from .sniffer import Sniffer +from .portscanner import PortScanner + + +class Firewall: + """Specialized utilities for testing firewall functionality""" + + def __init__(self, source=None, dest=None): + """ + Initialize firewall tester + Args: + source: Source network namespace (for traffic generation) + dest: Destination network namespace (for traffic capture) + """ + self.srcns = source + self.dstns = dest + + def verify_snat(self, dest_ip: str, snat_ip: str, + timeout: int = 3) -> Tuple[bool, str]: + """ + Verify SNAT (masquerading) by analyzing source IP of ICMP traffic + + Args: + dest_ip: Destination IP address to ping + snat_ip: Expected source IP after SNAT (router's WAN IP) + timeout: Test timeout in seconds + + Returns: + Tuple of (snat_working: bool, details: str) + """ + + try: + sniffer = Sniffer(self.dstns, "icmp") + with sniffer: + time.sleep(0.5) + self.srcns.runsh(f"ping -c3 -W{timeout} {dest_ip}") + time.sleep(0.5) + + rc = sniffer.output() + packets = rc.stdout + if rc.returncode or not packets.strip(): + return False, "No packets captured — routing may be broken" + + lines = packets.strip().split('\n') + snat_ip_found = False + lan_ip_found = False + + for line in lines: + if not line.strip(): + continue + + # Check if we see the expected SNAT IP as source + if f"{snat_ip} > {dest_ip}" in line: + snat_ip_found = True + + # Check if we see any other source IP (SNAT not working) + if f"> {dest_ip}" in line and snat_ip not in line: + parts = line.split() + for part in parts: + if f"> {dest_ip}" in part: + src_ip = part.split('>')[0].strip() + if '.' in src_ip and src_ip != snat_ip: + lan_ip_found = True + break + + if snat_ip_found and not lan_ip_found: + return True, f"SNAT working: only traffic from {snat_ip}" + if lan_ip_found and not snat_ip_found: + return False, f"SNAT broken: LAN IPs visible, no {snat_ip}" + if snat_ip_found and lan_ip_found: + return False, f"SNAT broken: both {snat_ip} and LAN IPs on WAN" + + return False, f"Unclear SNAT status, see capture:\n{packets}" + + except Exception as e: + return False, f"SNAT verification failed with error: {e}" + + def verify_blocked(self, dest_ip: str, ports: List[Tuple[int, str, str]] = None, + exempt: List[int] = None, timeout: int = 3) -> Tuple[bool, List[str], List[str]]: + """ + Verify specified ports are blocked, with optional exceptions + Args: + dest_ip: Target hostname or IP address + ports: List of port tuples, defaults to + PortScanner.WELL_KNOWN_PORTS + exempt: List of ports that should be excempt + timeout: Connection timeout per port + Returns: + When exempt=None: Tuple of (all_blocked: bool, open_ports: List[str], []) + When exempt=[...]: Tuple of (policy_correct: bool, unexpected_open: List[str], + unexpected_filtered_allowed: List[str]) + """ + if ports is None: + ports = PortScanner.WELL_KNOWN_PORTS + + scanner = PortScanner(self.srcns) + results = scanner.scan_ports(dest_ip, ports, timeout) + + if exempt is None: + # Simple "all blocked" behavior - only "open" is bad + open_ports = [] + for port, name, result in results: + if result["status"] == "open": + open_ports.append(f"{name}({port})") + return len(open_ports) == 0, open_ports, [] + + unexpected_open = [] + unexpected_filtered_allowed = [] + + for port, name, result in results: + if port in exempt: + # This port should be allowed (not filtered by firewall) + status = result["status"] + if status in ["filtered", "open|filtered", "closed|filtered"]: + unexpected_filtered_allowed.append(f"{name}({port})") + else: + # This port should be blocked - only "open" is bad + if result["status"] == "open": + unexpected_open.append(f"{name}({port})") + + policy_correct = (len(unexpected_open) == 0 and + len(unexpected_filtered_allowed) == 0) + return policy_correct, unexpected_open, unexpected_filtered_allowed + + def verify_allowed(self, dest_ip: str, ports: List[Tuple[int, str, str]] = None, + timeout: int = 3) -> Tuple[bool, List[str]]: + """ + Verify specified ports are allowed (open or closed, not filtered) + Args: + dest_ip: Target hostname or IP address + ports: List of port tuples, defaults to + PortScanner.WELL_KNOWN_PORTS + timeout: Connection timeout per port + Returns: + Tuple of (all_allowed: bool, filtered_ports: List[str]) + """ + if ports is None: + ports = PortScanner.WELL_KNOWN_PORTS + + scanner = PortScanner(self.srcns) + results = scanner.scan_ports(dest_ip, ports, timeout) + filtered_ports = [] + + for port, name, result in results: + status = result["status"] + # Consider any form of filtering as "not allowed" + if status in ["filtered", "open|filtered", "closed|filtered"]: + filtered_ports.append(f"{name}({port})") + + return len(filtered_ports) == 0, filtered_ports + + def verify_dnat(self, gateway_ip: str, forward_port: int, target_port: int, + timeout: int = 5) -> Tuple[bool, str]: + """ + Verify DNAT (port forwarding) by testing end-to-end connectivity + + Args: + gateway_ip: Gateway IP where port forwarding is configured + forward_port: External port being forwarded (e.g., 8080) + target_port: Internal target port (e.g., 80) + timeout: Connection timeout + Returns: + Tuple of (dnat_working: bool, details: str) + """ + try: + # Use netcat to simulate a simple service on target port + cmd = f"nc -l -p {target_port} -e /bin/echo 'DNAT-TEST-OK'" + pid = self.dstns.popen(cmd.split(), stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + time.sleep(1) # Give server time to start + + # Test connection from source to gateway:forward_port + cmd = f"nc -w {timeout} {gateway_ip} {forward_port}" + result = self.srcns.runsh(cmd) + + try: + pid.terminate() + pid.wait(timeout=1) + except: + pid.kill() + + # Check if we got the expected response + if "DNAT-TEST-OK" in result.stdout: + return True, f"DNAT working: {gateway_ip}:{forward_port} → target:{target_port}" + if result.returncode == 0: + return True, f"DNAT working: connection successful to {gateway_ip}:{forward_port}" + + return False, f"DNAT failed: no response from {gateway_ip}:{forward_port}" + + except Exception as e: + return False, f"DNAT verification failed with error: {e}" diff --git a/test/infamy/netns.py b/test/infamy/netns.py index 317e006db..2edb63cbb 100644 --- a/test/infamy/netns.py +++ b/test/infamy/netns.py @@ -248,6 +248,14 @@ def ping(self, daddr, id=None, timeout=None): stdout=subprocess.PIPE, stderr=subprocess.STDOUT, input=f"while :; do {ping} && break; done") + def ping6(self, daddr, timeout=None): + timeout = timeout if timeout else self.ping_timeout + ping6 = f"ping6 -c1 -w1 {daddr}" + + return self.run(["timeout", str(timeout), "/bin/sh"], text=True, check=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + input=f"while :; do {ping6} && break; done") + def must_reach(self, *args, **kwargs): self.ping(*args, **kwargs) @@ -259,6 +267,17 @@ def must_not_reach(self, *args, **kwargs): raise Exception(res) + def must_reach6(self, *args, **kwargs): + self.ping6(*args, **kwargs) + + def must_not_reach6(self, *args, **kwargs): + try: + res = self.ping6(*args, **kwargs) + except subprocess.CalledProcessError as e: + return + + raise Exception(res) + def must_receive(self, expr, ifname, timeout=None, must=True): timeout = timeout if timeout else self.ping_timeout diff --git a/test/infamy/portscanner.py b/test/infamy/portscanner.py new file mode 100644 index 000000000..26f56349a --- /dev/null +++ b/test/infamy/portscanner.py @@ -0,0 +1,153 @@ +""" +Port scanning utilities + +Lightweight wrapper around nmap for automated firewall testing. +Supports: + +- Scanning individual TCP/UDP ports with detailed state detection +- Parallel scanning of multiple ports using threads +- Predefined set of common service ports for convenience +""" +import threading +from typing import List, Dict, Union, Tuple + + +class PortScanner: + """Simple port scanner using netcat for firewall testing""" + + # Well-known ports for testing common services + WELL_KNOWN_PORTS = [ + (22, "tcp", "ssh"), + (53, "udp", "dns"), + (67, "udp", "dhcp"), + (69, "udp", "tftp"), + (80, "tcp", "http"), + (443, "tcp", "https"), + (5353, "udp", "mdns"), + (7681, "tcp", "ttyd"), + (8080, "tcp", "http-alt"), + (8443, "tcp", "https-alt"), + (7, "tcp", "echo"), + (1234, "tcp", "test-mid"), + (9999, "tcp", "test-high"), + ] + + def __init__(self, netns=None): + """ + Initialize port scanner + Args: + netns: Network namespace object (IsolatedMacVlan) or netns name + """ + self.netns = netns + + def scan_port(self, host: str, port: int, protocol: str = "tcp", + timeout: int = 3) -> Dict[str, Union[str, bool]]: + """ + Scan a single port using nmap for accurate firewall state detection + Args: + host: Target hostname or IP address + port: Port number to scan + protocol: 'tcp' or 'udp' + timeout: Connection timeout in seconds + Returns: + Dict with keys: 'open' (bool), 'status' (str), 'response' (str) + """ + # Build optimized nmap command based on protocol + if protocol.lower() == "tcp": + proto = "-sT" + elif protocol.lower() == "udp": + proto = "-sU" + else: + raise ValueError(f"Unsupported protocol: {protocol}") + + cmd = f"nmap -n {proto} -Pn -p {port} --host-timeout={timeout} " \ + f"--min-rate=1000 --max-retries=1 --disable-arp-ping {host}" + result = self.netns.runsh(cmd) + + # Parse nmap output to determine port state + output = result.stdout + + # Look for the specific port line in nmap output + # Format: "PORT STATE SERVICE" or "22/tcp open ssh" + port_line = None + for line in output.split('\n'): + if f"{port}/" in line and protocol in line: + port_line = line.strip() + break + + if port_line: + if "open|filtered" in port_line: + # No ICMP port unreachable, likely firewall dropping silently + status = "open|filtered" + is_open = False + elif "closed|filtered" in port_line: + # No service running, or firewall dropping + status = "closed|filtered" + is_open = False + elif "open" in port_line: + status = "open" + is_open = True + elif "filtered" in port_line: + # Blocked/Rejected by firewall + status = "filtered" + is_open = False + elif "closed" in port_line: + # Port reachable but service not running + status = "closed" + is_open = False + else: + # Unknown state + status = "unknown" + is_open = False + else: + # No port line found - likely filtered or error + status = "filtered" + is_open = False + + return { + "open": is_open, + "status": status, + "response": output.strip() + } + + def scan_ports(self, host: str, + port_specs: List[Tuple[int, str, str]], + timeout: int = 3) -> List[Tuple[int, str, Dict]]: + """ + Scan multiple ports in parallel using threads + Args: + host: Target hostname or IP address + port_specs: List (port, protocol, name) e.g. + [(80, "tcp", "http"), (53, "udp", "dns")] + timeout: Connection timeout per port + Returns: + List of (port, name, result) scan results. + """ + results = [] + threads = [] + lock = threading.Lock() + + def scan_worker(port: int, protocol: str, name: str): + try: + result = self.scan_port(host, port, protocol, timeout) + with lock: + results.append((port, name, result)) + except Exception as e: + with lock: + results.append((port, name, { + "open": False, + "status": "error", + "response": str(e) + })) + + for port, protocol, name in port_specs: + thread = threading.Thread(target=scan_worker, args=(port, protocol, name)) + threads.append(thread) + thread.start() + + for thread in threads: + thread.join() + + # Sort results by port number for consistent output + results.sort(key=lambda x: x[0]) + return results diff --git a/utils/srload b/utils/srload index 65d9a14a1..11bcbae40 100755 --- a/utils/srload +++ b/utils/srload @@ -84,7 +84,7 @@ enable() { local module=$1 local feature=$2 - echo "*** Enable feature $feature in $module." + #echo "*** Enable feature $feature in $module." $SYSREPOCTL -c $module -e $feature -v2 local rc=$? if [ $rc -ne 0 ]; then @@ -119,7 +119,7 @@ for module in "${MODULES[@]}"; do if [ -z "$SCTL_MODULE" ]; then # prepare command to install module with all its features - echo "*** Installing YANG model $name ..." + #echo "*** Installing YANG model $name ..." install "$module" continue fi @@ -164,7 +164,7 @@ done # install all the new modules if [ -n "${CMD_INSTALL}" ]; then - printf "*** Installing YANG models ...\n%s" "$CMD_INSTALL" + printf "*** Installing YANG models from %s ...\n%s" "$(basename "$1")" "$CMD_INSTALL" eval $CMD_INSTALL rc=$? if [ $rc -ne 0 ]; then