Merge pull request #1114 from kernelkit/fw

Add basic zone-based firewall
This commit is contained in:
Joachim Wiberg
2025-10-10 16:25:33 +02:00
committed by GitHub
93 changed files with 6752 additions and 36 deletions
+1
View File
@@ -41,6 +41,7 @@ jobs:
pipx inject mkdocs mkdocs-callouts
pipx inject mkdocs mike
pipx inject mkdocs mkdocs-to-pdf
pipx inject mkdocs mkdocs-glightbox
# Workaround, if pipx inject fails to install symlink
ln -s "$(pipx environment -V PIPX_LOCAL_VENVS)/mkdocs/bin/mike" \
"$(pipx environment -V PIPX_BIN_DIR)/mike" || true
+1 -1
View File
@@ -1,6 +1,6 @@
[![License Badge][]][License] [![GitHub Status][]][GitHub] [![Coverity Status][]][Coverity Scan] [![Discord][discord-badge]][discord-url]
<img align="right" src="doc/logo.png" alt="Infix - Linux <3 NETCONF" width=480 border=10>
<img align="right" src="doc/logo.png" alt="Infix — Immutable.Friendly.Secure" width=480 border=10>
Turn any ARM or x86 device into a powerful, manageable network appliance
in minutes. From $35 Raspberry Pi boards to enterprise switches — deploy
@@ -0,0 +1,3 @@
service [2345] <!pid/syslogd> reload:'firewall-cmd -q --reload' \
firewalld --nofork --log-target syslog \
-- Firewall daemon
@@ -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
+9 -2
View File
@@ -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
+1
View File
@@ -73,6 +73,7 @@ BR2_PACKAGE_CONNTRACK_TOOLS=y
BR2_PACKAGE_DNSMASQ=y
BR2_PACKAGE_ETHTOOL=y
BR2_PACKAGE_FPING=y
BR2_PACKAGE_FIREWALL=y
BR2_PACKAGE_FRR=y
# BR2_PACKAGE_IFUPDOWN_SCRIPTS is not set
BR2_PACKAGE_IPERF3=y
+1
View File
@@ -67,6 +67,7 @@ BR2_PACKAGE_AVAHI_DEFAULT_SERVICES=y
BR2_PACKAGE_CHRONY=y
BR2_PACKAGE_DNSMASQ=y
BR2_PACKAGE_ETHTOOL=y
BR2_PACKAGE_FIREWALL=y
BR2_PACKAGE_FRR=y
# BR2_PACKAGE_IFUPDOWN_SCRIPTS is not set
BR2_PACKAGE_IPROUTE2=y
+1
View File
@@ -91,6 +91,7 @@ BR2_PACKAGE_CONNTRACK_TOOLS=y
BR2_PACKAGE_DNSMASQ=y
BR2_PACKAGE_ETHTOOL=y
BR2_PACKAGE_FPING=y
BR2_PACKAGE_FIREWALL=y
BR2_PACKAGE_FRR=y
# BR2_PACKAGE_IFUPDOWN_SCRIPTS is not set
BR2_PACKAGE_IPERF3=y
+1
View File
@@ -86,6 +86,7 @@ BR2_PACKAGE_CONNTRACK_TOOLS=y
BR2_PACKAGE_DNSMASQ=y
BR2_PACKAGE_ETHTOOL=y
BR2_PACKAGE_FPING=y
BR2_PACKAGE_FIREWALL=y
BR2_PACKAGE_FRR=y
# BR2_PACKAGE_IFUPDOWN_SCRIPTS is not set
BR2_PACKAGE_IPERF3=y
+1
View File
@@ -71,6 +71,7 @@ BR2_PACKAGE_CONNTRACK_TOOLS=y
BR2_PACKAGE_DNSMASQ=y
BR2_PACKAGE_ETHTOOL=y
BR2_PACKAGE_FPING=y
BR2_PACKAGE_FIREWALL=y
BR2_PACKAGE_FRR=y
# BR2_PACKAGE_IFUPDOWN_SCRIPTS is not set
BR2_PACKAGE_IPERF3=y
+1
View File
@@ -65,6 +65,7 @@ BR2_PACKAGE_AVAHI_DEFAULT_SERVICES=y
BR2_PACKAGE_CHRONY=y
BR2_PACKAGE_DNSMASQ=y
BR2_PACKAGE_ETHTOOL=y
BR2_PACKAGE_FIREWALL=y
BR2_PACKAGE_FRR=y
# BR2_PACKAGE_IFUPDOWN_SCRIPTS is not set
BR2_PACKAGE_IPROUTE2=y
+50
View File
@@ -1,3 +1,53 @@
* TODO Add support for firewall
- [X] All "implicit" policies in zones are now policies: intra- and inter-zone policies
- [X] Add locked rules for implicit drop/reject policy as last rule in policy (ANY, ANY)
- [X] Firewall logs should show IN=iface (IIF) before SOURCE
- [X] Interfaces are not defaulting to the default zone, must handle bridge
ports and changes to enslavement, so regenerate every time is a must!
- [ ] firewalld helpers -- possibly for conntrack, e.g., ftp
- [ ] With =modprobe br_netfilter= firewalld would see *all* traffic, but there are
issues, <https://github.com/firewalld/firewalld/issues/1236>, and limits to what
seem to be possible atm. You may also need to enable these callbacks:
=echo 1 | sudo tee /proc/sys/net/bridge/bridge-nf-call-iptables=
=echo 1 | sudo tee /proc/sys/net/bridge/bridge-nf-call-ip6tables=
=echo 1 | sudo tee /proc/sys/net/bridge/bridge-nf-call-arptables=
- [X] Add missing upper to port forward since port ranges are supported, see services!
- [X] =[do] show firewall= not available yet
- [X] Add RPC to pause firewall using =firewall-cmd --panic-on= and restart
firewall again with =firewall-cmd --panic-off=. The current state can
be queried using =firewall-cmd --query-panic=, which returns =yes=
- [X] Remove debug log messages!
- [X] Add "Log Messages" section to =show firewall= when =LogDenied ≠ off=
- [ ] Investigate filtering out firewall log messages from other log files
- [1/2] Rename policy->policy to policy->action, and replace allow->forward
- [X] Rename zone->sources to networks
- [X] A zone's action is for ingress, clarify this if missing!
- [X] Any services/ports listed in a zone with policy:accept are a NO-OP
- [X] =firwall-cmd --reload= takes fooooorever! :-(
- [X] If forwarding is disabled in a zone then the zone matrix should
show deny for the same zone-to-zone communication
- [X] We should show the implicit rules for communicating with the HOST
- [X] Investigate "padlock" on built-in policys (and zones?) and expose more?
- [X] Document established,related somewhere, fixed/padlocked policy? Also,
document why this is a good idea to always have enabled. See RH docs.
- [ ] Podman published ports, <https://firewalld.org/2024/11/strict-forward-ports>
- [ ] Software fastpath <https://firewalld.org/2023/05/nftables-flowtable>
- +[ ] Allow overriding/editing immutable policies and zones+
- [X] Add tests: basic (end device), wan-lan, wan-lan-dmz, +hammer (stress)+
- [X] Add documentation
- See <https://docs.rockylinux.org/guides/security/firewalld-beginners/>
- Add some tool tips: nc, nmap, ping, and socat to stress the firewall
- [X] Fix inference so we can remove defaults from factory-config!
- [X] Add iperf service
- [X] Add nftables ownership=yes setting, introduced in later firewalld versions
- [ ] Investigate fail2ban integration with firewalld, for more info, see:
https://github.com/firewalld/firewalld/issues/1466#issuecomment-2773130569
- [ ] Update screenshots for documentation
- [ ] Review both cli-pretty and yanger code
- [ ] Review default-zone handling (needed?)
- [ ] Clean up =INFER_POLICY= ifdefs
- [ ] Revisit built-in fallback zones (public, block, drop)
* TODO doc: User Guide
- Feature set and scope, e.g.
+14
View File
@@ -3,6 +3,10 @@
line-height: 2.6rem;
}
.md-typeset code {
font-size: .75em;
}
[data-md-color-primary="orange"] {
--md-primary-fg-color: #ff7f2a;
--md-primary-bg-color: #5c5f5c;
@@ -10,3 +14,13 @@
[data-md-color-primary="black"] {
--md-primary-bg-color: #5c5f5c;
}
/* Center Markdown Tables (requires md_in_html extension) */
.center-table {
text-align: center;
}
.md-typeset .center-table :is(td,th):not([align]) {
/* Reset alignment for table cells */
text-align: initial;
}
+460
View File
@@ -0,0 +1,460 @@
![Firewall](img/firewall.svg){ align=left width="60" }
# Firewall Documentation
## Introduction
A zone-based firewall aims to *simplify network security*. Instead of complex
per-interface rules, you work with **zones** and **policies**. Briefly, ^^zones
define a level of trust^^ for all interfaces or networks assigned to it, and
^^policies regulate the traffic flow^^ between zones.
![Zone based concept](img/fw-concept.svg){ width=600 }
/// figure-caption
Zones group interfaces, policies control traffic flows.
///
Three distinct traffic flows exist: traffic destined for the host itself,
traffic between interfaces within the same zone (intra-zone), and traffic
between different zones (inter-zones).
---
The zone approach is not just more intuitive and maintainable, it allows you
to think more in terms of trust relationships:
- "internal networks can access the Internet"
- "Internet cannot access my internal network, except this port forward"
When you add new interfaces to existing zones, they automatically inherit the
established security policies. The amount of actual rules *that matter to
you* is kept to a minimum.
> [!TIP] Impatient and ready to get going?
> [Fast forward to the Examples: End Device, Home/Office Router, Enterprise Gateway](#examples)
## Visual Overview
Use the **zone matrix** to quickly audit your firewall configuration and
identify potential security gaps. It provides an overview and shows the
relationship between zones and the policies that connect them. Each cell in
the matrix represents a potential traffic flow, with rows indicating the
ingress zone and columns the egress zone.
![Firewall Matrix](img/fw-matrix.png)
/// figure-caption
Example output from <kbd>show firewall</kbd> command.
///
The matrix uses visual indicators to show the status of each zone and policy:
**✓ Green checkmark** — traffic is explicitly allowed by policy
**✗ Red cross** — traffic is blocked (default behavior)
**⚠ Yellow warning** — watch out! Some traffic allowed, such as port forwarding rules
This visualization helps you quickly understand your firewall's behavior and
identify any unintended gaps or overly permissive rules in your configuration.
> [!TIP] Use the ++question++ key in the CLI
> This admin-exec (top-level) CLI command has sub-commands that you can use to
> drill down on the operational data. Tap the ++question++ key once to see an
> overview after <kbd>show firewall</kbd>, or just use the classic UNIX ++tab++ key to
> complete everything until you've found your command.
## Zones
Zones are logical groupings of network interfaces or IP networks 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 available *services* (e.g., DHCP, DNS, SSH) that
devices in the DMZ can use to reach the host.
> [!IMPORTANT]
> Interfaces and networks are mutually exclusive in zones — attempting to
> configure both will result in a validation error. When setting up
> [*port forwarding*](#port-forwarding) from one zone to another, the
> destination network must be declared in a zone.
### Default Zone
You must specify a default zone. This serves as a safety net: any interface
not explicitly assigned to a zone automatically belongs to the default zone,
ensuring network interfaces remain protected by the firewall. This automatic
assignment is particularly useful when configuring new interfaces (e.g.,
VLANs, bridges, or hotplugged devices).
Choose your default zone carefully — it should be the most restrictive zone
appropriate for unmanaged interfaces. For routers, this is typically the
`wan` zone, but you can of course also set up a dedicated `block` zone. In the
CLI, when first enabling the firewall, a `public` zone is created. See more
about this in the [example below](#end-device-protection).
> [!IMPORTANT] Remember IP forwarding on interfaces!
> Firewall policies only control whether traffic is allowed on input, to be
> forwarded, or blocked (default). For the actual routing between interfaces
> to work, you must also enable [IP forwarding](networking.md#ipv4-forwarding)
> on the relevant interfaces.
### Intra-Zone Traffic
Traffic between different interfaces, or networks, in the same zone is not
forwarded by default. In most cases, if devices on separate interfaces need
to communicate, they should be in different zones with a policy between them.
Alternatively, if you want true LAN-like behavior, [bridge the interfaces][1]
at layer-2 instead of routing between them at layer-3.
*Intra-zone* forwarding — routing traffic within a single zone — is rarely
needed. But if you do require it, create a policy where both ingress and
egress are set to the same zone, e.g., `lan``lan`.
### Port Forwarding
Port forwarding, also known as destination NAT (DNAT), redirects inbound
traffic to another address and/or port. This allows external access to
internal services. See [Enterprise Gateway](#enterprise-gateway) for an
example.
Each zone can have port forwarding rules that apply to traffic arriving at
that zone's interfaces or matching its networks. The forwarded traffic must
then be allowed by appropriate policies to reach the destination zone.
The *Zone Matrix* shows a ⚠ conditional warning flag, coloring the cell
yellow, when exceptions like port forwarding are active.
## Policies
In short, policy rules control traffic **between** zones. By default all
inter-zone (and intra-zone) traffic is rejected. Meaning you must explicitly
allow the traffic flows you intend.
![Zone based firewall](img/fw-zones.svg){ width=600 }
/// figure-caption
Example of common traffic flows (policies) between zones.
///
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 on the host any zone interface and network are
allowed access to.
> [!NOTE]
> Policy rules apply in-order, the first matching rule with action `drop` will
> terminate the traffic flow. Use action `continue` to allow processing to go
> to the next rule, until the last (implicit) default-drop rule at the end.
>
> The CLI currently does not support reordering rules. As a workaround, save
> your `running-config` to `startup-config`, then exit to the shell and edit
> the file with `edit /cfg/startup-config.cfg`.
See the [examples below](#enterprise-gateway) for how to set up a policy. The
built-in help system can also be useful:
<code><pre>admin@example:/config/firewall/policy/lan-to-dmz/> <b>help masquerade</b>
<b>NAME</b>
masquerade <true/false><br/>
<b>DESCRIPTION</b>
Enable masquerading (SNAT) for traffic matching this policy.<br/>
Matching traffic will have their source IP address changed on egress,
using the IP address of the interface the traffic egresses.";<br/>
admin@example:/config/firewall/policy/lan-to-dmz/>
</pre></code>
### 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 automatic IPv6 address
assignment.
- `HOST``ANY`: Control device to any outbound connection (default: allowed)
- `ANY``HOST`: Control what can reach device services (uncommon, usually per-zone)
- Zone → `HOST`: Allow specific zone to access device services
### Custom Filters
For more advanced firewall scenarios *custom filters* can be used. Currently
only various ICMP type traffic control is supported. Enough to support the
built-in `allow-host-ipv6` policy and allow certain ICMP types on input or
forward.
You can inspect this built-in (locked) policy from admin-exec level with the
command: <kbd>show firewall policy allow-host-ipv6</kbd>.
### Default Behavior
ICMP messages (particularly `echo-request` and `echo-reply`) can be used to
reveal network information for malicious purposes. Therefore, the firewall
blocks ICMP requests by default. This applies unless the zone's default
action is `accept`.
To enable `echo-request` (IPv4) for any interface, or per zone when action is
set to drop or reject, set up a dedicated policy with `ingress ANY` and `egress
HOST` that use a custom filter to accept that ICMP type. Make this policy the
first rule in your list of policies, and remember to use `continue` for
non-matching traffic.
Another built-in behavior is automatically allowing "established,related"
return traffic flows. This uses connection tracking - the firewall remembers
outbound connections you initiate and automatically allows the corresponding
inbound response traffic. This means you only need to configure rules for
new connections; the firewall handles return traffic automatically without
additional rules.
## 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.
The firewall includes over 100 pre-defined services, such as:
- **`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)
> [!TIP] Use the ++question++ key in the CLI
> See the YANG model for the full list, or tap the ++question++ key
> when setting up an allowed host service in a zone `set service`
## Examples
### End Device Protection
This is the default firewall setup, useful for end devices on untrusted
networks. It provides maximum protection while allowing essential
connectivity.
<code><pre>admin@example:/> <b>configure</b>
admin@example:/config/> <b>edit firewall</b>
admin@example:/config/firewall/> <b>show</b>
default public;
zone public {
action reject;
description "Public, unknown network. Only SSH and DHCPv6 client allowed.";
service dhcpv6-client;
service ssh;
}
admin@example:/config/firewall/> <b>leave</b>
</pre></code>
The `reject` action differs from `drop` in that it responds to ICMP messages,
although maybe not how you may think. Pinging the device we may[^1] see this:
<code><pre>
<b>$</b> ping 192.168.122.161
From 192.168.122.161 icmp_seq=1 <u>Packet filtered</u>
</pre></code>
If we run `tcpdump` it shows us why:
<code><pre>
<b>$</b> tcpdump -lni eth0
20:10:40.245707 IP 192.168.122.1 > 192.168.122.161: ICMP echo request, id 56838, seq 1, length 64
20:10:40.245961 IP 192.168.122.161 > 192.168.122.1: ICMP <u>host 192.168.122.161 unreachable - admin prohibited filter</u>, length 92
</pre></code>
The key here is that, yes the device responds, but not with `ICMP reply` but
`ICMP unreachable`, and a little helpful message.
The default zone is `public`, so all interfaces that are not explicitly
assigned to another zone will be operationally placed in this zone as a
safeguard. Inspect this from admin-exec context with <kbd>show firewall</kbd>, as can
be seen in the below screenshot, the only interface `e1` has been assigned
automatically to the public zone. This information is also saved to the
system log.
![Initial firewall setup with default zone](img/fw-default.png)
/// figure-caption
Zone matrix and firewall overview from <kbd>show firewall</kbd>.
///
> [!IMPORTANT]
> These defaults are *inferred* for interactive CLI users. Enabling the
> firewall using NETCONF/RESTCONF will not yield the same results.
[^1]: The output from ping clients differ A LOT. Some do not consider ICMP
unreachable to be a proper response and it will appear as if the device is
not responding at all. Use `tcpdump` or `wireshark` to get to the bottom
of network mysteries.
### 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.
<code><pre>admin@example:/> <b>configure</b>
admin@example:/config/> <b>edit firewall</b>
admin@example:/config/firewall/> <b>set default wan</b>
admin@example:/config/firewall/> <b>edit zone lan</b>
admin@example:/config/firewall/zone/lan/> <b>set description "Internal LAN network - trusted"</b>
admin@example:/config/firewall/zone/lan/> <b>set action accept</b>
admin@example:/config/firewall/zone/lan/> <b>set interface eth1</b>
admin@example:/config/firewall/zone/lan/> <b>set service ssh</b>
admin@example:/config/firewall/zone/lan/> <b>set service dhcp</b>
admin@example:/config/firewall/zone/lan/> <b>set service dns</b>
admin@example:/config/firewall/zone/lan/> <b>end</b>
admin@example:/config/firewall/> <b>edit zone wan</b>
admin@example:/config/firewall/zone/wan/> <b>set description "External WAN interface - untrusted"</b>
admin@example:/config/firewall/zone/wan/> <b>set action drop</b>
admin@example:/config/firewall/zone/wan/> <b>set interface eth0</b>
admin@example:/config/firewall/zone/wan/> <b>end</b>
admin@example:/config/firewall/> <b>edit policy loc-to-wan</b>
admin@example:/config/firewall/policy/loc-to-wan/> <b>set description "Allow LAN traffic to WAN with SNAT"</b>
admin@example:/config/firewall/policy/loc-to-wan/> <b>set ingress lan</b>
admin@example:/config/firewall/policy/loc-to-wan/> <b>set egress wan</b>
admin@example:/config/firewall/policy/loc-to-wan/> <b>set action accept</b>
admin@example:/config/firewall/policy/loc-to-wan/> <b>set masquerade</b>
admin@example:/config/firewall/policy/loc-to-wan/> <b>leave</b>
</pre></code>
### 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.
<code><pre>admin@example:/> <b>configure</b>
admin@example:/config/> <b>edit firewall zone dmz</b>
admin@example:/config/firewall/zone/dmz/> <b>set description "Semi-trusted public services"</b>
admin@example:/config/firewall/zone/dmz/> <b>set action drop</b>
admin@example:/config/firewall/zone/dmz/> <b>set interface eth1</b>
admin@example:/config/firewall/zone/dmz/> <b>set service ssh</b>
admin@example:/config/firewall/zone/dmz/> <b>end</b>
admin@example:/config/firewall/> <b>edit policy loc-to-wan</b>
admin@example:/config/firewall/policy/loc-to-wan/> <b>set description "Allow local networks (LAN+DMZ) to WAN with SNAT"</b>
admin@example:/config/firewall/policy/loc-to-wan/> <b>set ingress dmz</b>
admin@example:/config/firewall/policy/loc-to-wan/> <b>set egress wan</b>
admin@example:/config/firewall/policy/loc-to-wan/> <b>set action accept</b>
admin@example:/config/firewall/policy/loc-to-wan/> <b>set masquerade</b>
admin@example:/config/firewall/policy/loc-to-wan/> <b>end</b>
admin@example:/config/firewall/> <b>edit policy lan-to-dmz</b>
admin@example:/config/firewall/policy/lan-to-dmz/> <b>set description "Allow LAN to manage DMZ services"</b>
admin@example:/config/firewall/policy/lan-to-dmz/> <b>set ingress lan</b>
admin@example:/config/firewall/policy/lan-to-dmz/> <b>set egress dmz</b>
admin@example:/config/firewall/policy/lan-to-dmz/> <b>set action accept</b>
admin@example:/config/firewall/policy/lan-to-dmz/> <b>end</b>
admin@example:/config/firewall/> <b>edit zone wan port-forward 8080 tcp</b>
admin@example:/config/firewall/zone/wan/port-forward/8080/tcp/> <b>set to addr 192.168.2.10</b>
admin@example:/config/firewall/zone/wan/port-forward/8080/tcp/> <b>set to port 80</b>
admin@example:/config/firewall/zone/wan/port-forward/8080/tcp/> <b>leave</b>
</pre></code>
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:
<code><pre>
admin@example:/> <b>configure</b>
admin@example:/config/> <b>edit firewall</b>
admin@example:/config/firewall/> <b>set logging all</b>
admin@example:/config/firewall/> <b>leave</b>
</pre></code>
Firewall logs help you understand traffic patterns and security events. The
CLI admin-exec command <kbd>show firewall</kbd> shows the last 10 log messages in the
overview:
![Firewall logs](img/fw-logs.png){width=100%}
/// figure-caption
Summary of recent logs at the end of <kbd>show firewall</kbd>.
///
Use the command <kbd>show log firewall.log</kbd> to display the full logfile
(remember, the syslog daemon rotates and zips too big log files). You can
also use the <kbd>follow firewall.log</kbd> command to continuously monitor
firewall log messages.
## Netfilter Integration
The Infix firewall operates through Linux netfilter hooks. Understanding how
the *zones* and *policy* concepts map to these hooks will hopefully help you
understand the firewall's behavior and ease troubleshooting.
### Packet Flow
![Netfilter hooks](img/fw-netfilter.svg){width=750}
/// figure-caption
Linux netfilter hooks in layer-3 traffic flow.
///
| **Netfilter Hook** | **Function** | **Description** |
|--------------------|--------------|--------------------------------------------------------------------------|
| `prerouting` | ZONE | Classification of incoming traffic, match interfaces/networks with zones |
| `prerouting` | ZONE | Port forwarding (DNAT) from zone configuration |
| `input` | ZONE | Host input filtering (`services`) |
| `input` | ZONE | Default action for non-matching services (`action`) |
| `forward` | POLICY | Allow traffic between zones (inter-zone rules) |
| `postrouting` | POLICY | Masquerade (SNAT) when traffic egresses a zone |
#### PREROUTING Hook
- **Zone Classification**: Traffic is tagged based on ingress interface or
source network
- **Port Forwarding**: DNAT from zone configuration occurs before routing decisions
- **Connection Tracking**: Early state establishment for stateful filtering
#### INPUT Hook
- **ANY-to-HOST Policies**: Enforces policy rules for traffic destined to the
host itself
- **Zone Services**: Allows configured services (SSH, HTTP, etc.) based on
zone trust level
- **Zone Action**: Applies a default action (accept/reject/drop) for
unmatched traffic
#### FORWARD Hook
- **Policy Enforcement**: Primary location for inter-zone traffic filtering
- **Custom Filters**: ICMP and other protocol-specific rules within policies
- **Service Matching**: Allows or denies services based on policy configuration
#### POSTROUTING Hook
- **Masquerading**: Source NAT for outbound traffic when policies enable masquerading
## Emergency Lockdown
For security emergencies (active breaches, suspicious activity), the firewall
supports an immediate lockdown mode that blocks ALL traffic.
> [!DANGER]
> This will immediately terminate all network connections, including SSH.
> Physical console access is required to restore normal operation. It is
> also possible to restore normal operation by power-cycling the device.
To activate emergency lockdown:
```json
~$ curl -kX POST -u admin:admin -H "Content-Type: application/yang-data+json" \
-d '{"infix-firewall:input": {"operation": "now"}}' \
https://example.local/restconf/operations/infix-firewall:firewall/lockdown-mode
```
To cancel lockdown mode (requires console access):
```json
~$ curl -kX POST -u admin:admin -H "Content-Type: application/yang-data+json" \
-d '{"infix-firewall:input": {"operation": "cancel"}}' \
https://example.local/restconf/operations/infix-firewall:firewall/lockdown-mode
```
You can check the current lockdown state:
```json
~$ curl -kX GET -u admin:admin -H 'Accept: application/yang-data+json' \
https://example.local/restconf/data/infix-firewall:firewall/lockdown
{
"infix-firewall:firewall": {
"lockdown": false
}
}
```
[1]: networking.md#bridging
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250.9 169.3" width="350" xmlns:v="https://vecta.io/nano"><path d="M128.1 92l-2.3 1.4-2.3-1.4c-.6-.4-7.6-4.7-16-13.2h-.2-2.7-10.2-.7H91h-2-9-19.1v21.5h19.2 9 4.7 13.7 35.3 13.7 9.8 3.9 1.3V78.8h-1.3-3.9-7.1-2.7-3.7-.8-7.9c-8.3 8.5-15.3 12.9-16 13.2zM17.5 143.7h49.7v22H17.5zm60.7 0h49.7v22H78.2zm-39.8-10.4h49.8v-22h-32-17.8zm60.2-22h49.7v22H98.6zM.7 79.3h49.7v21.5H.7zm175.8-32.4h-6.6-4.2c-3.7 8.6-8.2 15.9-12.8 22h8.7 44.5 3.2v-22h-32.8zm-138.1 0v22H99c-4.6-6.1-9.2-13.4-12.8-22H38.4z" fill="#aaa"/><g fill="#00be3b"><path d="M163 25.7c1.3-6 1.7-10.8 1.7-13.8v-.1-.9-.1c-7-3.5-14.1-6.2-21.2-7.8-5.4-1.3-11-2-16.6-2.1h-.1-1-1-.1c-5.6.1-11.2.8-16.6 2-7 1.7-14.1 4.4-21.1 7.9v1c.1 3.1.4 7.9 1.7 13.8 6.7 33.1 29.8 52 37.1 57.3 7.4-5.3 30.5-24.2 37.2-57.2z"/><circle cx="127.6" cy="38" r="26.9"/></g><path d="M122.2 51.2c-1 0-1.9-.4-2.6-1.2l-6.8-7.8c-1.2-1.4-1.1-3.6.3-4.8s3.6-1.1 4.8.3l4.3 4.9 15.1-16.7c1.3-1.4 3.4-1.5 4.8-.2s1.5 3.4.2 4.8L124.7 50c-.6.8-1.5 1.2-2.5 1.2z" fill="#fff"/><path d="M236.7 91.8l-6 28.2L218 73.5l-.1.3h0l-10.3 37.5-2.4 8.8-1.9-8.8-4.2-19.4c-2.5 5.2-5.4 12-8 19.4-2.4 7.1-4.4 14.7-5.2 22-.4 3.6-.5 7.1-.2 10.5.3 4.1 1.3 8 3 11.4.3.6.6 1.1.9 1.6 4.8 8 14.4 12.1 28.3 12.2l.1 2.6V169c13.9 0 23.4-4.1 28.3-12.2 4.6-7.6 5.2-18.7 2-33.1-2.7-11.7-7.4-23-11.6-31.9z" fill="#db9300"/><path d="M180.5 143.7h-42.1v22h50.3v-1.6c-1.4-1.4-2.6-3-3.6-4.7-2.6-4.4-4.2-9.6-4.6-15.7zm5.1-32.4h-8.4-18.3v22h21.9c.3-3.4.9-7 1.8-10.8.7-3.9 1.8-7.6 3-11.2z" fill="#aaa"/><path d="M217.4 117.4s-21.9 47.2.7 47.2c22.5 0-.7-47.2-.7-47.2" fill="#bd0001"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 120 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 281 KiB

+27 -1
View File
@@ -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
@@ -98,10 +99,18 @@ theme:
icon: material/weather-sunny
name: Switch to light mode
# https://squidfunk.github.io/mkdocs-material/reference/formatting/
markdown_extensions:
- admonition
- attr_list
- footnotes
- md_in_html
- pymdownx.blocks.caption
- pymdownx.critic
- pymdownx.caret
- pymdownx.keys
- pymdownx.mark
- pymdownx.tilde
- pymdownx.details
- pymdownx.superfences
- pymdownx.highlight:
@@ -118,6 +127,23 @@ plugins:
- search
- callouts
- mike
- glightbox:
touchNavigation: true
loop: false
effect: zoom
slide_effect: slide
width: 100%
height: auto
zoomable: true
draggable: true
skip_classes:
- custom-skip-class-name
auto_themed: true
auto_caption: false
caption_position: bottom
background: black
shadow: false
manual: false
- to-pdf:
cover: true
enabled_if_env: PDF_EXPORT
+1
View File
@@ -15,6 +15,7 @@ source "$BR2_EXTERNAL_INFIX_PATH/package/statd/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/factory/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/faux/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/finit/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/firewall/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/greenpak-programmer/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/ifupdown-ng/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/iito/Config.in"
+8
View File
@@ -0,0 +1,8 @@
config BR2_PACKAGE_FIREWALL
bool "firewall"
select BR2_PACKAGE_FIREWALLD
help
Meta pacakge to select firewall related packages for the OS
as well as adapt and integrate properly with the system.
https://github.com/kernelkit/infix
+90
View File
@@ -0,0 +1,90 @@
#!/bin/sh
set -e
TARGET_DIR="$1"
FIREWALL_SERVICES_YANG="$2"
FIREWALL_DAEMON_DIR="${TARGET_DIR}/usr/lib/firewalld"
# Cleanup — remove unnecessary firewalld files and create required directories
cleanup()
{
rm -rf "${TARGET_DIR}/etc/firewall"*
rm -f "${TARGET_DIR}/usr/bin/firewall-applet"
rm -rf "${TARGET_DIR}/usr/share/firewalld"
# Keep only the three zones required by firewalld (core/fw.py)
find "${FIREWALL_DAEMON_DIR}/zones" -type f \
! -name block.xml \
! -name drop.xml \
! -name trusted.xml \
-delete
mkdir -p "${TARGET_DIR}/etc/firewalld/zones"
mkdir -p "${TARGET_DIR}/etc/firewalld/policies"
mkdir -p "${TARGET_DIR}/etc/firewalld/services"
touch "${TARGET_DIR}/etc/firewalld/firewalld.conf"
mkdir -p "${FIREWALL_DAEMON_DIR}/services"
}
# Prune services — keep only those that match YANG enums
prune_services()
{
if [ ! -f "${FIREWALL_SERVICES_YANG}" ]; then
echo "ERROR: ${FIREWALL_SERVICES_YANG} not found"
exit 1
fi
# Extract enum values from YANG model
ENUMS=$(grep 'enum "' "${FIREWALL_SERVICES_YANG}" | \
sed 's/.*enum "\([^"]*\)".*/\1/')
# Validate that all YANG enums have corresponding .xml files
MISSING=0
for service in ${ENUMS}; do
if [ ! -f "${FIREWALL_DAEMON_DIR}/services/${service}.xml" ]; then
echo "Service ${service} is not a known firewalld service"
MISSING=1
fi
done
if [ ${MISSING} -eq 1 ]; then
exit 1
fi
# Remove .xml files that are not in YANG enums
cd "${FIREWALL_DAEMON_DIR}/services/"
for xmlfile in *.xml; do
service="${xmlfile%.xml}"
if ! echo "${ENUMS}" | grep -q "^${service}$"; then
rm "${xmlfile}"
fi
done
}
# Mark built-in zones and policies as immutable
mark_builtins()
{
FIREWALL_XML_FILES="${FIREWALL_DAEMON_DIR}/policies/*.xml ${FIREWALL_DAEMON_DIR}/zones/*.xml"
for xmlfile in ${FIREWALL_XML_FILES}; do
[ -f "${xmlfile}" ] || continue
grep -q "(immutable)" "${xmlfile}" && continue
if grep -q '<short>' "${xmlfile}"; then
sed -i 's|<short>\(.*\)</short>|<short>\1 (immutable)</short>|' \
"${xmlfile}"
else
if echo "${xmlfile}" | grep -q "/policies/"; then
sed -i 's|<policy|<short>(immutable)</short>\n&|' \
"${xmlfile}"
else
sed -i 's|<zone|<short>(immutable)</short>\n&|' \
"${xmlfile}"
fi
fi
done
}
cleanup
prune_services
mark_builtins
+25
View File
@@ -0,0 +1,25 @@
################################################################################
#
# Firewall support
#
################################################################################
FIREWALL_PACKAGE_VERSION = 1.0
FIREWALL_PACKAGE_LICENSE = MIT
FIREWALL_DEPENDENCIES = firewalld
FIREWALL_SERVICES_YANG = $(CONFD_SRCDIR)/yang/confd/infix-firewall-services.yang
# Copy custom service definitions and run finalization script
define FIREWALL_INSTALL_CUSTOM_SERVICES
mkdir -p $(TARGET_DIR)/usr/lib/firewalld/services
cp $(FIREWALL_PKGDIR)/services/*.xml $(TARGET_DIR)/usr/lib/firewalld/services/
endef
define FIREWALL_FINALIZE
$(FIREWALL_PKGDIR)/finalize.sh $(TARGET_DIR) $(FIREWALL_SERVICES_YANG)
endef
FIREWALL_POST_INSTALL_TARGET_HOOKS += FIREWALL_INSTALL_CUSTOM_SERVICES
FIREWALL_TARGET_FINALIZE_HOOKS += FIREWALL_FINALIZE
$(eval $(generic-package))
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<service>
<short>NETCONF</short>
<description>
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.
</description>
<port protocol="tcp" port="830"/>
</service>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<service>
<short>RESTCONF</short>
<description>
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.
</description>
<port protocol="tcp" port="443"/>
<port protocol="tcp" port="8443"/>
</service>
@@ -0,0 +1,40 @@
diff --git a/src/firewall/core/fw.py b/src/firewall/core/fw.py
index 0a51cfff..8afd6113 100644
--- a/src/firewall/core/fw.py
+++ b/src/firewall/core/fw.py
@@ -223,7 +223,7 @@ class Firewall(object):
self.ipset_backend.set_list()
except ValueError:
if self.nftables_enabled:
- log.info1("ipset not usable, disabling ipset usage in firewall. Other set backends (nftables) remain usable.")
+ log.debug1("ipset not usable, disabling ipset usage in firewall. Other set backends (nftables) remain usable.")
else:
log.warning("ipset not usable, disabling ipset usage in firewall.")
self.ipset_supported_types = [ ]
@@ -240,7 +240,7 @@ class Firewall(object):
"individual calls for IPv4 firewall.")
else:
if self.nftables_enabled:
- log.info1("iptables-restore and iptables are missing, "
+ log.debug1("iptables-restore and iptables are missing, "
"IPv4 direct rules won't be usable.")
else:
log.warning("iptables-restore and iptables are missing, "
@@ -260,7 +260,7 @@ class Firewall(object):
"individual calls for IPv6 firewall.")
else:
if self.nftables_enabled:
- log.info1("ip6tables-restore and ip6tables are missing, "
+ log.debug1("ip6tables-restore and ip6tables are missing, "
"IPv6 direct rules won't be usable.")
else:
log.warning("ip6tables-restore and ip6tables are missing, "
@@ -280,7 +280,7 @@ class Firewall(object):
"individual calls for bridge firewall.")
else:
if self.nftables_enabled:
- log.info1("ebtables-restore and ebtables are missing, "
+ log.debug1("ebtables-restore and ebtables are missing, "
"eb direct rules won't be usable.")
else:
log.warning("ebtables-restore and ebtables are missing, "
@@ -0,0 +1,45 @@
From 03f273fc540082d1eaa23bd9b5847e695afd8283 Mon Sep 17 00:00:00 2001
From: Joachim Wiberg <troglobit@gmail.com>
Date: Thu, 25 Sep 2025 15:00:54 +0200
Subject: [PATCH] Silence warnings about old backends
Organization: Wires
Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
---
src/firewall/core/fw.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/firewall/core/fw.py b/src/firewall/core/fw.py
index bf7c109a..90d556de 100644
--- a/src/firewall/core/fw.py
+++ b/src/firewall/core/fw.py
@@ -281,7 +281,7 @@ class Firewall:
)
else:
if self.nftables_enabled:
- log.info1(
+ log.debug1(
"iptables-restore and iptables are missing, "
"IPv4 direct rules won't be usable."
)
@@ -311,7 +311,7 @@ class Firewall:
)
else:
if self.nftables_enabled:
- log.info1(
+ log.debug1(
"ip6tables-restore and ip6tables are missing, "
"IPv6 direct rules won't be usable."
)
@@ -341,7 +341,7 @@ class Firewall:
)
else:
if self.nftables_enabled:
- log.info1(
+ log.debug1(
"ebtables-restore and ebtables are missing, "
"eb direct rules won't be usable."
)
--
2.43.0
+1 -1
View File
@@ -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
+403
View File
@@ -0,0 +1,403 @@
#!/bin/sh
# Firewall debug and management utility using D-Bus API
#
# SPDX-License-Identifier: BSD-3-Clause
DEST="org.fedoraproject.FirewallD1"
OBJECT="/org/fedoraproject/FirewallD1"
INTERFACE="org.fedoraproject.FirewallD1"
VERBOSE=0
print() {
if [ "$VERBOSE" -eq 1 ]; then
printf '%s\n' "$*"
fi
}
check_firewalld()
{
gdbus call --system --dest "$DEST" --object-path "$OBJECT" \
--method "$INTERFACE.getDefaultZone" >/dev/null 2>&1
}
call_reload()
{
output=$(gdbus call --system --dest "$DEST" --object-path "$OBJECT" \
--method "$INTERFACE.reload" 2>&1)
ret=$?
# Validate both return code and output
if [ $ret -eq 0 ] && [ "$output" = "()" ]; then
return 0
else
print "Error: Reload method failed (exit code: $ret, output: '$output')" >&2
return 1
fi
}
wait_for_reload()
{
timeout_val=$1
timeout "$timeout_val" gdbus monitor --system --dest "$DEST" \
--object-path "$OBJECT" 2>/dev/null | \
while IFS= read line; do
if echo "$line" | grep -q "Reloaded"; then
return 0
fi
done
print "Timeout waiting for firewall reload completion" >&2
return 1
}
gdbus_call()
{
method=$1
result=$(gdbus call --system --dest "$DEST" --object-path "$OBJECT" \
--method "$INTERFACE.$method" 2>/dev/null | \
sed 's/^(//; s/,)$//; s/[(),]//g' | tr -d ' ')
# Check if call succeeded (non-empty result indicates success)
if [ -n "$result" ]; then
echo "$result"
return 0
else
return 1
fi
}
is_panic_enabled()
{
result=$(gdbus_call "queryPanicMode")
if [ $? -eq 0 ] && [ "$result" = "true" ]; then
return 0
fi
return 1
}
panic_on()
{
is_panic_enabled && return 0
if ! gdbus call --system --dest "$DEST" --object-path "$OBJECT" \
--method "$INTERFACE.enablePanicMode" >/dev/null 2>&1; then
print "Error: Failed to activate lockdown mode" >&2
return 1
fi
logger -p user.emerg "LOCKDOWN MODE ACTIVATED - All network traffic blocked"
}
panic_off()
{
is_panic_enabled || return 0
if ! gdbus call --system --dest "$DEST" --object-path "$OBJECT" \
--method "$INTERFACE.disablePanicMode" >/dev/null 2>&1; then
print "Error: Failed to deactivate lockdown mode" >&2
return 1
fi
logger -p user.emerg "LOCKDOWN MODE DEACTIVATED - Normal network operation restored"
}
panic_status()
{
if is_panic_enabled; then
print "Lockdown mode: ACTIVE"
return 0
fi
print "Lockdown mode: INACTIVE"
return 1
}
show_status()
{
echo "=== Firewall Status ==="
if check_firewalld; then
echo " Firewalld : RUNNING"
else
echo "Firewalld NOT RUNNING"
return 1
fi
if is_panic_enabled; then
panic="on"
else
panic="off"
fi
echo " Lockdown Mode : $panic"
default_zone=$(gdbus_call "getDefaultZone" | sed "s/[']//g")
logging=$(gdbus_call "getLogDenied" | sed "s/[']//g")
echo " Default Zone : $default_zone"
echo " Log Denied : $logging"
echo
echo "=== Active Zones ==="
zones_output=$(gdbus call --system --dest "$DEST" --object-path "$OBJECT" \
--method org.fedoraproject.FirewallD1.zone.getActiveZones 2>/dev/null | \
sed 's/^(//; s/,)$//' | tr "'" '"' | sed 's/@as \[\]/[]/g')
if echo "$zones_output" | jq -e . >/dev/null 2>&1; then
echo "$zones_output" | jq -r 'to_entries[] |
" \(.key):" +
(if (.value.interfaces | length) > 0 then "\n Interfaces: " + (.value.interfaces | join(", ")) else "" end) +
(if (.value.sources | length) > 0 then "\n Networks : " + (.value.sources | join(", ")) else "" end) +
(if (.value.interfaces | length) == 0 and (.value.sources | length) == 0 then "\n Members : (none)" else "" end)
' 2>/dev/null || echo " Failed to parse zones"
else
echo " No zones or failed to retrieve"
fi
echo
echo "=== Available Services ==="
services=$(gdbus call --system --dest "$DEST" --object-path "$OBJECT" \
--method "$INTERFACE.listServices" 2>/dev/null | \
sed 's/^(//; s/,)$//' | tr "'" '"')
if echo "$services" | jq -e . >/dev/null 2>&1; then
echo "$services" | jq -r '.[] | " " + .' 2>/dev/null | head -20
count=$(echo "$services" | jq -r '. | length' 2>/dev/null)
if [ "$count" -gt 20 ]; then
echo " ... and $((count - 20)) more"
fi
else
echo " Failed to retrieve services"
fi
echo
echo "=== Policies ==="
policies=$(gdbus call --system --dest "$DEST" --object-path "$OBJECT" \
--method org.fedoraproject.FirewallD1.policy.getPolicies 2>/dev/null | \
sed 's/^(//; s/,)$//' | tr "'" '"')
if echo "$policies" | jq -e . >/dev/null 2>&1; then
policy_count=$(echo "$policies" | jq -r '. | length' 2>/dev/null)
echo " Total policies: $policy_count"
if [ "$policy_count" -gt 0 ]; then
echo "$policies" | jq -r '.[]' 2>/dev/null | while read policy_name; do
echo " Policy: $policy_name"
policy_settings=$(gdbus call --system --dest "$DEST" --object-path "$OBJECT" \
--method org.fedoraproject.FirewallD1.policy.getPolicySettings \
"$policy_name" 2>/dev/null)
if [ -n "$policy_settings" ] && [ "${policy_settings#*Error}" = "$policy_settings" ]; then
target=$(echo "$policy_settings" | grep -o "'target': <'[^']*'" | cut -d"'" -f4)
description=$(echo "$policy_settings" | grep -o "'description': <'[^']*'" | cut -d"'" -f4)
masquerade=$(echo "$policy_settings" | grep -o "'masquerade': <[^>]*>" | sed "s/.*<\([^>]*\)>.*/\1/")
priority=$(echo "$policy_settings" | grep -o "'priority': <[^>]*>" | sed "s/.*<\([^>]*\)>.*/\1/")
if echo "$policy_settings" | grep -q "'ingress_zones'"; then
# Match: 'ingress_zones': <['internal']> or 'ingress_zones': <['dmz', 'internal']>
ingress_zones=$(echo "$policy_settings" | grep -o "'ingress_zones': <\[[^]]*\]>" | sed "s/'ingress_zones': <\[//; s/\]>//; s/'//g" | sed 's/, */, /g')
fi
if echo "$policy_settings" | grep -q "'egress_zones'"; then
# Match: 'egress_zones': <['external']> or 'egress_zones': <['HOST']>
egress_zones=$(echo "$policy_settings" | grep -o "'egress_zones': <\[[^]]*\]>" | sed "s/'egress_zones': <\[//; s/\]>//; s/'//g" | sed 's/, */, /g')
fi
if echo "$policy_settings" | grep -q "'rich_rules'"; then
rich_rules=$(echo "$policy_settings" | grep -o "'rich_rules': <\[[^]]*\]>" | sed "s/'rich_rules': <\[//; s/\]>//")
fi
# Extract port forwarding information
if echo "$policy_settings" | grep -q "'forward_ports'"; then
forward_ports=$(echo "$policy_settings" | grep -o "'forward_ports': <\[[^]]*\]>" | sed "s/'forward_ports': <\[//; s/\]>//")
fi
echo " Target : ${target:-unknown}"
echo " Description: ${description:-none}"
echo " Priority : ${priority:-unknown}"
echo " Ingress : ${ingress_zones:-none}"
echo " Egress : ${egress_zones:-none}"
echo " Masquerade : ${masquerade:-false}"
if [ -n "$rich_rules" ] && [ "$rich_rules" != "" ]; then
rule_count=$(echo "$rich_rules" | grep -o "'" | wc -l)
rule_count=$((rule_count / 2))
if [ "$rule_count" -gt 0 ]; then
echo " Rich Rules ($rule_count):"
# Extract individual rules
echo "$rich_rules" | grep -o "'[^']*'" | sed "s/'//g" | while read rule; do
echo " $rule"
done
else
echo " Rich Rules: none"
fi
else
echo " Rich Rules: none"
fi
# Display port forwarding rules
if [ -n "$forward_ports" ] && [ "$forward_ports" != "" ]; then
echo " Port FWD :"
# Parse forward_ports which contains tuples like ('8080', 'tcp', '80', '10.0.1.100')
# Extract individual port forward entries
echo "$forward_ports" | sed "s/), (/\n/g" | sed "s/^(//; s/)$//" | while IFS= read forward_rule; do
if [ -n "$forward_rule" ]; then
# Parse the tuple: 'from_port', 'protocol', 'to_port', 'to_addr'
from_port=$(echo "$forward_rule" | cut -d',' -f1 | sed "s/'//g" | tr -d ' ')
protocol=$(echo "$forward_rule" | cut -d',' -f2 | sed "s/'//g" | tr -d ' ')
to_port=$(echo "$forward_rule" | cut -d',' -f3 | sed "s/'//g" | tr -d ' ')
to_addr=$(echo "$forward_rule" | cut -d',' -f4 | sed "s/'//g" | tr -d ' ')
if [ -n "$from_port" ] && [ -n "$protocol" ] && [ -n "$to_addr" ] && [ -n "$to_port" ]; then
echo " ${from_port}/${protocol} → ${to_addr}:${to_port}"
else
echo " $forward_rule (unparsed)"
fi
fi
done
else
echo " Port FWD : none"
fi
else
echo " (Failed to get policy details)"
fi
echo
done
fi
else
echo " No policies or failed to retrieve"
fi
# Runtime info
echo "=== Runtime Information ==="
echo " nftables rules:"
rule_count=$(nft list ruleset 2>/dev/null | grep -c "^[[:space:]]*[^#]" || echo "0")
echo " Active rules: $rule_count"
table_count=$(nft list tables 2>/dev/null | wc -l || echo "0")
echo " Active tables: $table_count"
}
# Function to show usage
show_help()
{
cat << EOF
Usage: $0 [OPTIONS] COMMAND
OPTIONS:
--wait SEC Wait for reload completion signal (use with reload command)
-v, --verbose Enable verbose output for error messages and status
-h, --help Show this help message
COMMANDS:
reload Reload firewall configuration
panic OPERATION Emergency panic mode: <on | off | status>
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 "$@"
+1
View File
@@ -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 \
+3
View File
@@ -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;
+3
View File
@@ -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_ */
+66
View File
@@ -908,6 +908,72 @@ cleanup:
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;
+1
View File
@@ -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,
+740
View File
@@ -0,0 +1,740 @@
/* SPDX-License-Identifier: BSD-3-Clause */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <dirent.h>
#include <srx/common.h>
#include <srx/lyx.h>
#include <srx/srx_val.h>
#include <libyang/libyang.h>
#include "core.h"
#include "ietf-interfaces.h"
#define MODULE "infix-firewall"
#define XPATH "/infix-firewall:firewall"
#define INFER_POLICY 0
#define FIREWALLD_DIR "/etc/firewalld"
#define FIREWALLD_DIR_NEXT "/etc/firewalld+"
#define FIREWALLD_CONF FIREWALLD_DIR_NEXT "/firewalld.conf"
#define FIREWALLD_ZONES_DIR FIREWALLD_DIR_NEXT "/zones"
#define FIREWALLD_SERVICES_DIR FIREWALLD_DIR_NEXT "/services"
#define FIREWALLD_POLICIES_DIR FIREWALLD_DIR_NEXT "/policies"
static struct {
const char *yang;
const char *target;
} zone_action_map[] = {
{ "reject", "%%REJECT%%" },
{ "accept", "ACCEPT" },
{ "drop", "DROP" },
};
static struct {
const char *yang;
const char *target;
} policy_action_map[] = {
{ "continue", "CONTINUE" },
{ "accept", "ACCEPT" },
{ "reject", "REJECT" },
{ "drop", "DROP" },
};
static const char *zone_action_to_target(const char *action)
{
for (size_t i = 0; action && i < NELEMS(zone_action_map); i++) {
if (!strcmp(action, zone_action_map[i].yang))
return zone_action_map[i].target;
}
return zone_action_map[0].yang;
}
static const char *policy_action_to_target(const char *action)
{
for (size_t i = 0; action && i < NELEMS(policy_action_map); i++) {
if (!strcmp(action, policy_action_map[i].yang))
return policy_action_map[i].target;
}
return policy_action_map[0].yang;
}
static void mark_interfaces_used(struct lyd_node *cfg, char **ifaces)
{
struct lyd_node *node;
LYX_LIST_FOR_EACH(lyd_child(cfg), node, "interface") {
const char *ifname = lyd_get_value(node);
for (int i = 0; ifaces[i]; i++) {
if (!strcmp(ifaces[i], ifname)) {
ifaces[i][0] = '\0';
break;
}
}
}
}
static void log_unzoned(const char *name, char **ifaces)
{
size_t num = 0;
for (int i = 0; ifaces && ifaces[i]; i++) {
if (ifaces[i][0] != '\0')
num++;
}
if (num > 0) {
size_t sz = num * 16 + 2 * num + 1;
char buf[sz];
int hit = 0;
memset(buf, 0, sz);
for (int i = 0; ifaces[i]; i++) {
if (ifaces[i][0] == '\0')
continue;
if (hit)
strlcat(buf, ", ", sz);
strlcat(buf, ifaces[i], sz);
hit++;
}
WARN("Adding %zu unassigned interfaces to default zone '%s': %s",
num, name, buf);
}
}
static FILE *open_file(const char *dir, const char *name)
{
FILE *fp;
fp = fopenf("w", "%s/%s.xml", dir, name);
if (!fp) {
ERRNO("Failed creating %s/%s.xml: %s", dir, name, strerror(errno));
return NULL;
}
fprintf(fp, "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");
return fp;
}
static int close_file(FILE *fp)
{
fclose(fp);
return SR_ERR_OK;
}
static int delete_file(const char *dir, const char *name)
{
if (erasef("%s/%s.xml", dir, name) && errno != ENOENT) {
ERRNO("Failed deleting %s/%s.xml: %s", dir, name, strerror(errno));
return SR_ERR_SYS;
}
return SR_ERR_OK;
}
static int generate_zone(struct lyd_node *cfg, const char *name, char **ifaces)
{
const char *action, *desc;
struct lyd_node *node;
FILE *fp;
fp = open_file(FIREWALLD_ZONES_DIR, name);
if (!fp)
return SR_ERR_SYS;
action = lydx_get_cattr(cfg, "action");
desc = lydx_get_cattr(cfg, "description");
fprintf(fp, "<zone target=\"%s\">\n", zone_action_to_target(action));
fprintf(fp, " <short>%s</short>\n", name);
if (desc)
fprintf(fp, " <description>%s</description>\n", desc);
LYX_LIST_FOR_EACH(lyd_child(cfg), node, "interface")
fprintf(fp, " <interface name=\"%s\"/>\n", lyd_get_value(node));
if (ifaces) {
for (int i = 0; ifaces[i]; i++) {
if (ifaces[i][0] != '\0') {
fprintf(fp, " <interface name=\"%s\"/>\n", ifaces[i]);
}
}
log_unzoned(name, ifaces);
}
LYX_LIST_FOR_EACH(lyd_child(cfg), node, "network")
fprintf(fp, " <source address=\"%s\"/>\n", lyd_get_value(node));
LYX_LIST_FOR_EACH(lyd_child(cfg), node, "service")
fprintf(fp, " <service name=\"%s\"/>\n", lyd_get_value(node));
LYX_LIST_FOR_EACH(lyd_child(cfg), node, "port-forward") {
const char *lower = lydx_get_cattr(node, "lower");
const char *upper = lydx_get_cattr(node, "upper");
const char *proto = lydx_get_cattr(node, "proto");
struct lyd_node *to = lydx_get_child(node, "to");
if (to) {
const char *to_addr = lydx_get_cattr(to, "addr");
const char *to_port = lydx_get_cattr(to, "port");
if (upper) {
/* Port range */
fprintf(fp, " <forward-port port=\"%s-%s\" protocol=\"%s\"", lower, upper, proto);
if (to_addr)
fprintf(fp, " to-addr=\"%s\"", to_addr);
if (to_port)
fprintf(fp, " to-port=\"%s\"", to_port);
fprintf(fp, "/>\n");
} else {
/* Single port */
fprintf(fp, " <forward-port port=\"%s\" protocol=\"%s\"", lower, proto);
if (to_addr)
fprintf(fp, " to-addr=\"%s\"", to_addr);
if (to_port)
fprintf(fp, " to-port=\"%s\"", to_port);
fprintf(fp, "/>\n");
}
}
}
fprintf(fp, "</zone>\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, "<service>\n");
if (desc)
fprintf(fp, " <short>%s</short>\n", desc);
if (dest)
fprintf(fp, " <destination ipv%s=\"%s\"/>\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, " <port port=\"%s-%s\" protocol=\"%s\"/>\n", lower, upper, proto);
else
fprintf(fp, " <port port=\"%s\" protocol=\"%s\"/>\n", lower, proto);
}
fprintf(fp, "</service>\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, "<policy target=\"%s\" priority=\"%d\">\n",
policy_action_to_target(action), (*priority)++);
if (desc)
fprintf(fp, " <description>%s</description>\n", desc);
LYX_LIST_FOR_EACH(lyd_child(cfg), node, "ingress")
fprintf(fp, " <ingress-zone name=\"%s\"/>\n", lyd_get_value(node));
LYX_LIST_FOR_EACH(lyd_child(cfg), node, "egress")
fprintf(fp, " <egress-zone name=\"%s\"/>\n", lyd_get_value(node));
LYX_LIST_FOR_EACH(lyd_child(cfg), node, "service")
fprintf(fp, " <service name=\"%s\"/>\n", lyd_get_value(node));
/* 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, " <rule family=\"%s\" priority=\"%d\">\n",
family, (*priority)++);
else
fprintf(fp, " <rule priority=\"%d\">\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, " <icmp-block name=\"%s\"/>\n", type);
} else {
fprintf(fp, " <icmp-type name=\"%s\"/>\n", type);
fprintf(fp, " <%s/>\n", action);
}
}
fprintf(fp, " </rule>\n");
}
}
if (masquerade)
fprintf(fp, " <masquerade/>\n");
fprintf(fp, "</policy>\n");
return close_file(fp);
}
static int generate_firewalld_conf(struct lyd_node *cfg)
{
FILE *fp;
fp = fopen(FIREWALLD_CONF, "w");
if (!fp) {
ERRNO("Failed creating %s", FIREWALLD_CONF);
return SR_ERR_SYS;
}
fprintf(fp, "DefaultZone=%s\n", lydx_get_cattr(cfg, "default"));
fprintf(fp, "LogDenied=%s\n", lydx_get_cattr(cfg, "logging") ?: "off");
fprintf(fp, "FirewallBackend=nftables\n");
fprintf(fp, "IndividualCalls=no\n");
/*
* Set nftables rule set to be owned exclusively by firewalld.
* This prevents other entities from mistakenly (or maliciously)
* modifying firewalld's rule set -- e.g., 'nft flush ruleset'
* will not affect the firewalld rules.
*/
fprintf(fp, "NftablesTableOwner=yes\n");
/* TODO: add config option to enable nftables flowtable (fastpath) */
fprintf(fp, "NftablesFlowtable=off\n");
/* TODO: Add config option to enable this useful debug option. */
fprintf(fp, "NftablesCounters=no\n");
/* Drop all traffic, except established connections, while rules are updated */
fprintf(fp, "ReloadPolicy=INPUT:DROP,FORWARD:DROP,OUTPUT:DROP\n");
fprintf(fp, "FlushAllOnReload=yes\n");
/* Seamless integration with podman -- published ports are opened. */
fprintf(fp, "StrictForwardPorts=no\n");
/* Performs reverse path filtering (RPF) on IPv6 packets as per RFC 3704 */
fprintf(fp, "IPv6_rpfilter=loose-forward\n");
/*
* Filter IPv6 traffic with 6to4 destination addresses that correspond
* to IPv4 addresses that should not be routed over the public internet.
*/
fprintf(fp, "RFC3964_IPv4=yes\n");
/* Remove all firewall rules on exit */
fprintf(fp, "CleanupOnExit=yes\n");
fclose(fp);
return SR_ERR_OK;
}
static int infer_zone(sr_session_ctx_t *session, const char *name, const char *desc,
const char *action, const char *services[])
{
int rc;
DEBUG("Inferring zone %s (%s), action %s", name, desc, action);
rc = srx_set_str(session, desc, 0, XPATH "/zone[name='%s']/description", name);
if (rc)
return rc;
rc = srx_set_str(session, action, 0, XPATH "/zone[name='%s']/action", name);
if (rc)
return rc;
for (int i = 0; services && services[i]; i++) {
rc = srx_set_str(session, services[i], 0, XPATH "/zone[name='%s']/service[.='%s']",
name, services[i]);
if (rc)
return rc;
}
return SR_ERR_OK;
}
#if INFER_POLICY
static int infer_policy(sr_session_ctx_t *session, const char *name, const char *desc,
const char *action, const char *ingress[], const char *egress[],
const char *icmp_types[][4])
{
int rc;
DEBUG("Inferring policy %s (%s), action %s", name, desc, action);
rc = srx_set_str(session, desc, 0, XPATH "/policy[name='%s']/description", name);
if (rc)
return rc;
rc = srx_set_str(session, action, 0, XPATH "/policy[name='%s']/action", name);
if (rc)
return rc;
/* Set ingress zones */
for (int i = 0; ingress && ingress[i]; i++) {
rc = srx_set_str(session, ingress[i], 0, XPATH "/policy[name='%s']/ingress[.='%s']",
name, ingress[i]);
if (rc)
return rc;
}
/* Set egress zones */
for (int i = 0; egress && egress[i]; i++) {
rc = srx_set_str(session, egress[i], 0, XPATH "/policy[name='%s']/egress[.='%s']",
name, egress[i]);
if (rc)
return rc;
}
/* Set custom ICMP filters */
for (int i = 0; icmp_types && icmp_types[i][0]; i++) {
const char *family = icmp_types[i][0];
const char *filter = icmp_types[i][1];
const char *action = icmp_types[i][2];
const char *type = icmp_types[i][3];
rc = srx_set_str(session, family, 0,
XPATH "/policy[name='%s']/custom/filter[name='%s']/family",
name, filter);
if (rc)
return rc;
rc = srx_set_str(session, action, 0,
XPATH "/policy[name='%s']/custom/filter[name='%s']/action",
name, filter);
if (rc)
return rc;
rc = srx_set_str(session, type, 0,
XPATH "/policy[name='%s']/custom/filter[name='%s']/icmp/type",
name, filter);
if (rc)
return rc;
}
return SR_ERR_OK;
}
#endif
static int change(sr_session_ctx_t *session, uint32_t sub_id, const char *module,
const char *xpath, sr_event_t event, unsigned request_id, void *_confd)
{
struct lyd_node *tree, *global;
struct lyd_node *clist, *cnode;
struct lyd_node *diff = NULL;
sr_error_t err = SR_ERR_OK;
sr_data_t *cfg = NULL;
char **ifaces = NULL;
switch (event) {
case SR_EV_CHANGE:
/* Generate configuration to /etc/firewalld+ */
break;
case SR_EV_ABORT:
systemf("rm -rf " FIREWALLD_DIR_NEXT);
return SR_ERR_OK;
case SR_EV_DONE:
if (!fisdir(FIREWALLD_DIR_NEXT)) {
/* Firewall is disabled */
systemf("initctl -nbq disable firewalld");
return SR_ERR_OK;
}
/* Firewall is enabled, roll in new configuration */
systemf("rm -rf " FIREWALLD_DIR);
if (rename(FIREWALLD_DIR_NEXT, FIREWALLD_DIR)) {
ERRNO("Failed rolling in firewalld configuration");
return SR_ERR_SYS;
}
systemf("initctl -nbq touch firewalld");
systemf("initctl -nbq enable firewalld");
return SR_ERR_OK;
default:
return SR_ERR_OK;
}
err = sr_get_data(session, "//.", 0, 0, 0, &cfg);
if (err || !cfg)
return SR_ERR_INTERNAL;
tree = cfg->tree;
global = lydx_get_descendant(tree, "firewall", NULL);
/* Clean up any stale /etc/firewalld+ first */
systemf("rm -rf " FIREWALLD_DIR_NEXT);
/* If firewall is disabled or not enabled, don't generate config */
if (!global || !lydx_is_enabled(global, "enabled")) {
/* Firewall is disabled - no /etc/firewalld+ directory */
goto done;
}
/* Get L3 interfaces for default zone assignment */
if (ietf_interfaces_get_all_l3(tree, &ifaces) != 0) {
ERROR("Failed to get L3 interfaces");
ifaces = NULL;
}
err = srx_get_diff(session, &diff);
if (err)
goto err_release_data;
if (!diff)
goto err_release_data;
/* Create /etc/firewalld+ directory structure */
if (fmkpath(0755, FIREWALLD_DIR_NEXT) ||
fmkpath(0755, FIREWALLD_ZONES_DIR) ||
fmkpath(0755, FIREWALLD_SERVICES_DIR) ||
fmkpath(0755, FIREWALLD_POLICIES_DIR)) {
ERROR("Failed creating " FIREWALLD_DIR_NEXT " directory structure");
err = SR_ERR_SYS;
goto err_release_data;
}
if (lydx_get_descendant(diff, "firewall", "default", NULL) ||
lydx_get_descendant(diff, "firewall", "logging", NULL))
generate_firewalld_conf(global);
/*
* Regenerate everything if anything in firewall changed, firewalld
* handles the 'diff' for us. Starting priority for policies are at
* -14999 because at -15000 is the first "Allow host IPv6" immutable
* (default/built-in) policy from firewalld. We want the user rules
* to be between that and the default 'drop-all' implicit rule.
*/
if (lydx_get_descendant(diff, "firewall", NULL)) {
const char *default_zone = lydx_get_cattr(global, "default");
struct lyd_node *list, *node;
int priority = -14999;
/* First, handle explicit deletions by removing files */
list = lydx_get_descendant(diff, "firewall", "zone", NULL);
LYX_LIST_FOR_EACH(list, node, "zone") {
if (lydx_get_op(node) == LYDX_OP_DELETE)
delete_file(FIREWALLD_ZONES_DIR, lydx_get_cattr(node, "name"));
}
list = lydx_get_descendant(diff, "firewall", "service", NULL);
LYX_LIST_FOR_EACH(list, node, "service") {
if (lydx_get_op(node) == LYDX_OP_DELETE)
delete_file(FIREWALLD_SERVICES_DIR, lydx_get_cattr(node, "name"));
}
list = lydx_get_descendant(diff, "firewall", "policy", NULL);
LYX_LIST_FOR_EACH(list, node, "policy") {
if (lydx_get_op(node) == LYDX_OP_DELETE)
delete_file(FIREWALLD_POLICIES_DIR, lydx_get_cattr(node, "name"));
}
/* Regenerate all non-default zones first */
clist = lydx_get_descendant(tree, "firewall", "zone", NULL);
LYX_LIST_FOR_EACH(clist, cnode, "zone") {
const char *name = lydx_get_cattr(cnode, "name");
/* Skip default zone - we'll do it last */
if (!strcmp(name, default_zone))
continue;
mark_interfaces_used(cnode, ifaces);
generate_zone(cnode, name, NULL);
}
/* Generate default zone last with any unzoned interfaces */
clist = lydx_get_descendant(tree, "firewall", "zone", NULL);
LYX_LIST_FOR_EACH(clist, cnode, "zone") {
const char *name = lydx_get_cattr(cnode, "name");
if (strcmp(name, default_zone))
continue;
mark_interfaces_used(cnode, ifaces);
generate_zone(cnode, name, ifaces);
break;
}
/* Regenerate all services */
clist = lydx_get_descendant(tree, "firewall", "service", NULL);
LYX_LIST_FOR_EACH(clist, cnode, "service")
generate_service(cnode, lydx_get_cattr(cnode, "name"));
/* Regenerate all policies with sequential priority allocation */
clist = lydx_get_descendant(tree, "firewall", "policy", NULL);
LYX_LIST_FOR_EACH(clist, cnode, "policy") {
const char *name = lydx_get_cattr(cnode, "name");
if (generate_policy(cnode, name, &priority)) {
ERROR("Failed to generate policy %s", name);
goto err_release_data;
}
}
}
done:
if (ifaces) {
for (int i = 0; ifaces[i]; i++)
free(ifaces[i]);
free(ifaces);
}
if (diff)
lyd_free_tree(diff);
err_release_data:
if (cfg)
sr_release_data(cfg);
return err;
}
static int cand(sr_session_ctx_t *session, uint32_t sub_id, const char *module,
const char *path, sr_event_t event, unsigned request_id, void *priv)
{
const char *svc[] = {"ssh", "dhcpv6-client", NULL};
#if INFER_POLICY
const char *any[] = {"ANY", NULL};
const char *host[] = {"HOST", NULL};
const char *icmp_types[][4] = {
{"ipv6", "na", "accept", "neighbour-advertisement"},
{"ipv6", "ns", "accept", "neighbour-solicitation"},
{"ipv6", "ra", "accept", "router-advertisement"},
{"ipv6", "re", "accept", "redirect"},
{NULL, NULL, NULL, NULL}
};
#endif
size_t cnt = 0;
int rc;
if (event != SR_EV_UPDATE && event != SR_EV_CHANGE)
return 0;
if (!srx_enabled(session, XPATH "/enabled")) {
DEBUG("Deleted, or not enabled, not inferring anything.");
return 0;
}
/* If unset, this is the first time we're called */
if (srx_get_str(session, XPATH "/default"))
return 0;
rc = srx_nitems(session, &cnt, XPATH "/zones");
if (rc == 0 || cnt) {
WARN("firewall has %zu zone(s) defined, but no default zone! (rc %d)", cnt, rc);
return 0;
}
rc = infer_zone(session, "public", "Public, unknown network. Only SSH and DHCPv6 client allowed.",
"reject", svc);
if (rc)
return rc;
/* Set up default zone for new networks */
rc = srx_set_str(session, "public", 0, XPATH "/default");
if (rc)
return rc;
#if INFER_POLICY
/* Infer allow-host-ipv6 policy */
rc = infer_policy(session, "allow-host-ipv6",
"Allows basic IPv6 functionality for the host.",
"continue", any, host, icmp_types);
if (rc)
return rc;
#endif
return SR_ERR_OK;
}
static int lockdown(sr_session_ctx_t *session, uint32_t sub_id, const char *xpath,
const sr_val_t *input, const size_t input_cnt, sr_event_t event,
uint32_t request_id, sr_val_t **output, size_t *output_cnt, void *priv)
{
const char *operation = input->data.string_val;
int rc;
DEBUG("lockdown-mode: operation = %s", operation);
rc = systemf("firewall panic %s", strcmp(operation, "now") ? "off" : "on");
if (rc) {
ERROR("lockdown-mode: firewall command failed with exit code %d", rc);
return SR_ERR_OPERATION_FAILED;
}
return SR_ERR_OK;
}
int infix_firewall_init(struct confd *confd)
{
int rc;
REGISTER_CHANGE(confd->session, MODULE, XPATH "//.", 0, change, confd, &confd->sub);
REGISTER_CHANGE(confd->cand, MODULE, XPATH "//.", SR_SUBSCR_UPDATE, cand, confd, &confd->sub);
REGISTER_RPC(confd->session, XPATH "/lockdown-mode", lockdown, NULL, &confd->sub);
return SR_ERR_OK;
fail:
ERROR("init failed: %s", sr_strerror(rc));
return rc;
}
+3
View File
@@ -28,6 +28,9 @@ MODULES=(
"infix-dhcp-common@2025-01-29.yang"
"infix-dhcp-client@2025-01-29.yang"
"infix-dhcp-server@2025-01-29.yang"
"infix-firewall@2025-04-26.yang"
"infix-firewall-services@2025-04-26.yang"
"infix-firewall-icmp-types@2025-04-26.yang"
"infix-meta@2024-10-18.yang"
"infix-system@2025-01-25.yang"
"infix-services@2024-12-03.yang"
@@ -0,0 +1,169 @@
module infix-firewall-icmp-types {
yang-version 1.1;
namespace "urn:infix:firewall:icmp-types:ns:yang:1.0";
prefix ifw-icmp;
organization "KernelKit";
contact "kernelkit@googlegroups.com";
description "Common well-defined network services.";
revision 2025-04-26 {
description "Initial revision.";
reference "internal";
}
/* firewall-cmd --get-icmptypes */
typedef type {
description "Available ICMP/ICMPv6 types.";
type enumeration {
enum address-unreachable {
description "Error sent when a packet cannot be delivered to its IPv6 destination address.";
}
enum bad-header {
description "IPv6 error indicating there is a problem with the packet header structure or format.";
}
enum beyond-scope {
description "IPv6 error sent when transmitting a packet would cross a zone boundary of the source address scope.";
}
enum communication-prohibited {
description "Error indicating that communication with the destination has been administratively blocked.";
}
enum destination-unreachable {
description "General error sent by hosts or gateways when a destination cannot be reached.";
}
enum echo-reply {
description "Response message sent back to acknowledge receipt of an echo request (ping response/pong).";
}
enum echo-request {
description "Test message used to check if a host is reachable, commonly sent by the ping utility.";
}
enum failed-policy {
description "IPv6 error indicating the source address failed to meet ingress or egress policy requirements.";
}
enum fragmentation-needed {
description "IPv4 error sent when a packet needs fragmentation but the 'Don't Fragment' flag is set.";
}
enum host-precedence-violation {
description "IPv4 error sent when communication is administratively prohibited due to precedence rules.";
}
enum host-prohibited {
description "IPv4 error indicating that access from a specific host has been administratively blocked.";
}
enum host-redirect {
description "IPv4 message instructing to redirect packets to a different route for the specific host.";
}
enum host-unknown {
description "IPv4 error sent when the destination host cannot be identified or located.";
}
enum host-unreachable {
description "IPv4 error sent when the destination host exists but cannot be reached.";
}
enum ip-header-bad {
description "IPv4 error indicating malformed or corrupted IP header information.";
}
enum mld-listener-done {
description "IPv6 multicast message sent when a host leaves a multicast group.";
}
enum mld-listener-query {
description "IPv6 multicast router query to discover group membership information.";
}
enum mld-listener-report {
description "IPv6 multicast message sent by hosts to report group membership.";
}
enum mld2-listener-report {
description "IPv6 multicast listener report message for MLDv2 enhanced reporting.";
}
enum neighbour-advertisement {
description "IPv6 message sent in response to neighbor solicitation to propagate new network information.";
}
enum neighbour-solicitation {
description "IPv6 message used to discover link-layer addresses of neighbors and verify reachability.";
}
enum network-prohibited {
description "IPv4 error sent when access to an entire network has been administratively blocked.";
}
enum network-redirect {
description "IPv4 message instructing to redirect packets to a different route for the entire network.";
}
enum network-unknown {
description "IPv4 error sent when the destination network cannot be identified or located.";
}
enum network-unreachable {
description "IPv4 error sent when the destination network exists but cannot be reached.";
}
enum no-route {
description "IPv6 error sent when there is no routing table entry available for the destination.";
}
enum packet-too-big {
description "IPv6 error sent by routers when they cannot forward a packet because it exceeds the MTU.";
}
enum parameter-problem {
description "Error sent when IP header contains bad parameters or missing required options.";
}
enum port-unreachable {
description "Error sent when the destination port on a reachable host is not available or not listening.";
}
enum precedence-cutoff {
description "IPv4 error sent when the packet's precedence level is lower than the required minimum.";
}
enum protocol-unreachable {
description "IPv4 error sent when the specified protocol is not supported at the destination.";
}
enum redirect {
description "General message instructing a host to use a different route for future packets.";
}
enum reject-route {
description "IPv6 error sent when the routing table explicitly rejects the route to the destination.";
}
enum required-option-missing {
description "IPv4 error sent when a mandatory IP option is not present in the packet header.";
}
enum router-advertisement {
description "Message sent by routers to periodically announce their presence and network configuration.";
}
enum router-solicitation {
description "Message sent by hosts to request router advertisements and discover available routers.";
}
enum source-quench {
description "IPv4 flow control message telling a host to reduce its packet transmission rate.";
}
enum source-route-failed {
description "IPv4 error sent when source routing specified in the packet cannot be completed.";
}
enum time-exceeded {
description "Error sent when a packet's time-to-live expires during transit or reassembly.";
}
enum timestamp-reply {
description "IPv4 response message containing timestamp information for network time synchronization.";
}
enum timestamp-request {
description "IPv4 message requesting timestamp information from the destination for time synchronization.";
}
enum tos-host-redirect {
description "IPv4 message instructing to redirect packets based on both the type of service and specific host.";
}
enum tos-host-unreachable {
description "IPv4 error sent when a host is unreachable for the specific type of service requested.";
}
enum tos-network-redirect {
description "IPv4 message instructing to redirect packets based on both the type of service and network.";
}
enum tos-network-unreachable {
description "IPv4 error sent when a network is unreachable for the specific type of service requested.";
}
enum ttl-zero-during-reassembly {
description "Error sent when a host fails to completely reassemble fragmented packets within the time limit.";
}
enum ttl-zero-during-transit {
description "Error sent when a packet's time-to-live counter reaches zero while being forwarded.";
}
enum unknown-header-type {
description "IPv6 error sent when an unrecognized Next Header type is encountered in the packet.";
}
enum unknown-option {
description "IPv6 error sent when an unrecognized or unsupported IPv6 option is encountered.";
}
}
}
}
@@ -0,0 +1 @@
infix-firewall-icmp-types.yang
@@ -0,0 +1,386 @@
module infix-firewall-services {
yang-version 1.1;
namespace "urn:infix:firewall:services:ns:yang:1.0";
prefix ifw-svc;
organization "KernelKit";
contact "kernelkit@googlegroups.com";
description "Common well-defined network services.";
revision 2025-04-26 {
description "Initial revision.";
reference "internal";
}
typedef well-known-service {
description "Well-known network services, with standard port assignments from IANA.";
type enumeration {
enum "amqp" {
description "5672/tcp — Advanced Message Queuing Protocol for message-oriented middleware";
}
enum "amqps" {
description "5671/tcp — Secure Advanced Message Queuing Protocol over SSL";
}
enum "apcupsd" {
description "3551/tcp — APC uninterruptible power supply daemon protocol";
}
enum "audit" {
description "60/tcp — Linux audit subsystem for security event logging";
}
enum "bacula" {
description "9101-9103/tcp — Open source network backup tool";
}
enum "bacula-client" {
description "9102/tcp — Bacula client for backup operations";
}
enum "bgp" {
description "179/tcp — Border Gateway Protocol for internet routing";
}
enum "cockpit" {
description "9090/tcp — Web-based server administration interface";
}
enum "dhcp" {
description "67-68/udp — Dynamic Host Configuration Protocol for network configuration";
}
enum "dhcpv6" {
description "547/udp — Allow incoming DHCP for IPv6 requests from clients or relay agents.";
}
enum "dhcpv6-client" {
description "546/udp — Allow a DHCP for IPv6 client to obtain a lease.";
}
enum "dns" {
description "53/tcp+udp — Domain Name System for name resolution";
}
enum "dns-over-quic" {
description "853/udp — DNS over QUIC protocol for encrypted domain name resolution";
}
enum "dns-over-tls" {
description "853/tcp — DNS over TLS protocol for encrypted domain name resolution";
}
enum "elasticsearch" {
description "9300/tcp — Distributed search and analytics engine";
}
enum "ftp" {
description "20-21/tcp — File Transfer Protocol for file transfers";
}
enum "git" {
description "9418/tcp — Git daemon for version control repository access";
}
enum "grafana" {
description "3000/tcp — Analytics and monitoring dashboard platform";
}
enum "gpsd" {
description "2947/tcp — GPS daemon for location services";
}
enum "gre" {
description "47/ip — Generic Routing Encapsulation for tunneling";
}
enum "http" {
description "80/tcp — Hypertext Transfer Protocol for web traffic";
}
enum "https" {
description "443/tcp — Secure Hypertext Transfer Protocol for encrypted web traffic";
}
enum "http3" {
description "443/udp — HTTP/3 protocol over QUIC for faster web traffic";
}
enum "imap" {
description "143/tcp — Internet Message Access Protocol for email access";
}
enum "imaps" {
description "993/tcp — Secure Internet Message Access Protocol for encrypted email access";
}
enum "ipp" {
description "631/tcp+udp — Internet Printing Protocol (IPP) is used for distributed printing.";
}
enum "iperf2" {
description "5001/tcp — Network bandwidth measurement tool version 2";
}
enum "iperf3" {
description "5201/tcp — Network bandwidth measurement tool version 3";
}
enum "ipsec" {
description "500/udp — Internet Protocol Security for VPN connections";
}
enum "irc" {
description "6667/tcp — Internet Relay Chat for text messaging";
}
enum "ircs" {
description "6697/tcp — Secure Internet Relay Chat over SSL";
}
enum "jenkins" {
description "8080/tcp — Open source automation server for CI/CD";
}
enum "kerberos" {
description "88/tcp+udp — Network authentication protocol";
}
enum "kadmin" {
description "749/tcp — Kerberos administration server";
}
enum "kibana" {
description "5601/tcp — Data visualization dashboard for Elasticsearch";
}
enum "klogin" {
description "543/tcp — Kerberos remote login";
}
enum "kpasswd" {
description "464/tcp+udp — Kerberos password changing protocol";
}
enum "kprop" {
description "754/tcp — Kerberos database propagation";
}
enum "kshell" {
description "544/tcp — Kerberos remote shell";
}
enum "ldap" {
description "389/tcp — Lightweight Directory Access Protocol for directory services";
}
enum "ldaps" {
description "636/tcp — Lightweight Directory Access Protocol over SSL";
}
enum "libvirt" {
description "16509/tcp — Virtualization management daemon";
}
enum "libvirt-tls" {
description "16514/tcp — Secure virtualization management over TLS";
}
enum "llmnr" {
description "5355/tcp+udp — Link-Local Multicast Name Resolution";
}
enum "llmnr-client" {
description "5355/udp — LLMNR client for name resolution";
}
enum "llmnr-tcp" {
description "5355/tcp — LLMNR over TCP";
}
enum "llmnr-udp" {
description "5355/udp — LLMNR over UDP";
}
enum "matrix" {
description "8008/tcp — Matrix chat protocol server";
}
enum "mdns" {
description "5353/udp — Multicast DNS for local network service discovery";
}
enum "mongodb" {
description "27017/tcp — Document-oriented NoSQL database";
}
enum "minecraft" {
description "25565/tcp — Minecraft game server";
}
enum "minidlna" {
description "8200/tcp — Lightweight DLNA/UPnP media server";
}
enum "mndp" {
description "5678/udp — MikroTik Neighbor Discovery Protocol";
}
enum "mosh" {
description "60000-61000/udp — Mobile shell for remote terminal access";
}
enum "mpd" {
description "6600/tcp — Music Player Daemon";
}
enum "mqtt" {
description "1883/tcp — Message Queuing Telemetry Transport for IoT";
}
enum "mqtt-tls" {
description "8883/tcp — Secure MQTT over TLS for IoT";
}
enum "mssql" {
description "1433/tcp — Microsoft SQL Server database";
}
enum "mysql" {
description "3306/tcp — MySQL database server connections";
}
enum "nbd" {
description "10809/tcp — Network Block Device";
}
enum "netbios-ns" {
description "137/udp — NetBIOS Name Service for Windows networking";
}
enum "netconf" {
description "830/tcp — Network Configuration Protocol for network device management";
}
enum "nfs" {
description "2049/tcp+udp — Network File System for distributed file sharing";
}
enum "nfs3" {
description "2049/tcp+udp — Network File System version 3";
}
enum "nmea-0183" {
description "10110/tcp — Marine electronics data interface";
}
enum "nrpe" {
description "5666/tcp — Nagios Remote Plugin Executor";
}
enum "ntp" {
description "123/udp — Network Time Protocol for time synchronization";
}
enum "openvpn" {
description "1194/udp — OpenVPN secure tunnel for VPN connections";
}
enum "opentelemetry" {
description "4317/tcp — Open source observability framework";
}
enum "pop3" {
description "110/tcp — Post Office Protocol version 3 for email retrieval";
}
enum "pop3s" {
description "995/tcp — Secure Post Office Protocol version 3 for encrypted email retrieval";
}
enum "plex" {
description "32400/tcp — Plex media server";
}
enum "postgresql" {
description "5432/tcp — PostgreSQL database server connections";
}
enum "prometheus" {
description "9090/tcp — Monitoring system and time series database";
}
enum "proxy-dhcp" {
description "4011/udp — Proxy DHCP for PXE boot";
}
enum "ptp" {
description "319-320/udp — Precision Time Protocol";
}
enum "puppetmaster" {
description "8140/tcp — Puppet configuration management server";
}
enum "radius" {
description "1812-1813/tcp+udp — Remote Authentication Dial-in User Service";
}
enum "quassel" {
description "4242/tcp — Quassel IRC client-server protocol";
}
enum "radsec" {
description "2083/tcp — RADIUS over TLS";
}
enum "rdp" {
description "3389/tcp — Remote Desktop Protocol for Windows remote access";
}
enum "redis" {
description "6379/tcp — In-memory data structure store for database and cache";
}
enum "redis-sentinel" {
description "26379/tcp — Redis Sentinel for high availability";
}
enum "restconf" {
description "443/tcp — RESTful Network Configuration Protocol for HTTP-based network management";
}
enum "rsyncd" {
description "873/tcp — Rsync daemon for centralized file synchronization";
}
enum "rtsp" {
description "554/tcp — Real Time Streaming Protocol";
}
enum "samba" {
description "445/tcp — Windows file and printer sharing";
}
enum "samba-client" {
description "138/udp — Windows file and printer sharing (client-only)";
}
enum "samba-dc" {
description "389/tcp — Samba Active Directory Domain Controller";
}
enum "sane" {
description "6566/tcp — Scanner Access Now Easy network scanning";
}
enum "sip" {
description "5060/tcp+udp — Session Initiation Protocol for VoIP communications";
}
enum "sips" {
description "5061/tcp+udp — Secure Session Initiation Protocol for encrypted VoIP";
}
enum "smtp" {
description "25/tcp — Simple Mail Transfer Protocol for email transmission";
}
enum "smtps" {
description "465/tcp — Secure Simple Mail Transfer Protocol over SSL";
}
enum "slp" {
description "427/tcp+udp — Service Location Protocol";
}
enum "snmp" {
description "161/udp — Simple Network Management Protocol for network monitoring";
}
enum "snmptrap" {
description "162/udp — Simple Network Management Protocol trap notifications";
}
enum "snmptls" {
description "10161/tcp — SNMP over TLS";
}
enum "snmptls-trap" {
description "10162/tcp — SNMP trap over TLS";
}
enum "spotify-sync" {
description "57621/tcp — Spotify Connect synchronization";
}
enum "ssh" {
description "22/tcp — Secure Shell for remote login and command execution";
}
enum "stun" {
description "3478/tcp+udp — Session Traversal Utilities for NAT";
}
enum "stuns" {
description "5349/tcp — Secure STUN over TLS";
}
enum "supertuxkart" {
description "7321/tcp — SuperTuxKart racing game server";
}
enum "svn" {
description "3690/tcp — Subversion version control system";
}
enum "syslog" {
description "514/udp — System logging protocol for log message transmission";
}
enum "syslog-tls" {
description "6514/tcp — Secure system logging protocol over TLS";
}
enum "syncthing" {
description "22000/tcp — Continuous file synchronization";
}
enum "syncthing-gui" {
description "8384/tcp — Syncthing web interface";
}
enum "syncthing-relay" {
description "22067/tcp — Syncthing relay protocol";
}
enum "synergy" {
description "24800/tcp — Keyboard and mouse sharing";
}
enum "ssdp" {
description "1900/udp — Simple Service Discovery Protocol for UPnP device discovery";
}
enum "telnet" {
description "23/tcp — Telnet protocol for remote terminal access";
}
enum "tftp" {
description "69/udp — Trivial File Transfer Protocol for simple file transfers";
}
enum "turn" {
description "3478/tcp+udp — Traversal Using Relay NAT for firewall traversal";
}
enum "turns" {
description "5349/tcp — Secure TURN over TLS";
}
enum "vnc-server" {
description "5900-5906/tcp — Virtual Network Computing server for remote desktop access";
}
enum "vrrp" {
description "112/ip — Virtual Router Redundancy Protocol";
}
enum "warpinator" {
description "42000/tcp — File sharing tool by Linux Mint";
}
enum "wireguard" {
description "51820/udp — Modern VPN tunnel for secure networking";
}
enum "xdmcp" {
description "177/tcp+udp — X Display Manager Control Protocol for remote X11 sessions";
}
enum "zerotier" {
description "9993/udp — ZeroTier virtual network service";
}
}
}
}
@@ -0,0 +1 @@
infix-firewall-services.yang
+495
View File
@@ -0,0 +1,495 @@
module infix-firewall {
yang-version 1.1;
namespace "urn:infix:firewall:ns:yang:1.0";
prefix ifw;
import ietf-inet-types {
prefix inet;
reference "RFC 6991: Common YANG Data Types";
}
import ietf-interfaces {
prefix if;
reference "RFC 8343: A YANG Data Model for Interface Management";
}
import infix-firewall-icmp-types {
prefix ifw-icmp;
reference "internal";
}
import infix-firewall-services {
prefix ifw-svc;
reference "internal";
}
organization "KernelKit";
contact "kernelkit@googlegroups.com";
description "Zone-based firewall inspired by firewalld concepts.";
revision 2025-04-26 {
description "Initial revision.";
reference "internal";
}
/*
* Type definitions
*/
typedef ident {
description "Generic filesystem-safe identifier (filename).";
type string {
length "2..64";
pattern '[a-zA-Z0-9\-_]+';
}
}
typedef zone-action {
description "Default action for a zone.";
type enumeration {
enum accept {
description "Accept all connections by default.";
}
enum reject {
description "Reject all connections, except ICMP, by default.";
}
enum drop {
description "Drop all connections by default.";
}
}
}
typedef zone-ref {
description "Reference to a named zone or symbolic value: 'HOST' or 'ANY'.";
type union {
type enumeration {
enum HOST {
description "Refers to the local host/device itself";
}
enum ANY {
description "Refers to any zone";
}
}
type leafref {
path "../../zone/name";
}
}
}
typedef policy-action {
type enumeration {
enum continue {
description "Non-terminal policy. Matching traffic is accepted or allowed to proceed, and other policies continue to be evaluated.";
}
enum accept {
description "Accept matching traffic and stop evaluating further policies.";
}
enum reject {
description "Reject matching traffic (e.g., send ICMP unreachable) and stop evaluating further policies.";
}
enum drop {
description "Silently drop matching traffic and stop evaluating further policies.";
}
}
description "Action for traffic that does not match any specific service or port entry.";
}
typedef protocol-type {
description "Network protocols supported for services and port definitions.";
type enumeration {
enum tcp {
description "TCP protocol.";
}
enum udp {
description "UDP protocol.";
}
enum sctp {
description "SCTP protocol.";
}
enum dccp {
description "DCCP protocol.";
}
}
}
/*
* Main container and configuration
*/
container firewall {
description "Zone-based firewall configuration.";
presence "Activate firewall.";
leaf enabled {
description "Enable or disable the firewall.
Note, by disabling the firewall all rules are unloaded from the kernel, making
the system fully open! This can be useful when debugging firewall issues, but
remember to re-enable when done, and maybe remove connections to the Internet
before disabling.";
type boolean;
default true;
}
leaf default {
description "Default zone for interfaces.
Any interface not explicitly associated with a zone is placed in this zone.";
type leafref {
path "../zone/name";
}
mandatory true;
}
leaf logging {
description "Enable logging of denied (rejected/dropped) packets.
Add logging rules right before reject and drop rules in the INPUT, FORWARD and
OUTPUT chains for the default rules and also final reject and drop rules in
zones for the configured link-layer packet type.";
type enumeration {
enum all {
description "Log all denied packets.";
}
enum unicast {
description "Log unicast denied packets.";
}
enum broadcast {
description "Log broadcast denied packets.";
}
enum multicast {
description "Log multicast denied packets.";
}
enum off {
description "Do not log denied packets.";
}
}
default off;
}
list zone {
description "A zone defines a level of trust for network connections.";
key "name";
must "count(interface) = 0 or count(network) = 0" {
error-message "A zone cannot have both interfaces and networks - use interfaces for local traffic or networks for forwarding";
}
leaf name {
description "Name of the zone.";
type ident;
}
leaf action {
description "Default action for traffic from this zone to HOST.
When 'accept', all traffic from this zone can reach HOST services.
When 'reject' or 'drop', only explicitly listed services are allowed
to reach HOST, all other traffic is rejected/dropped. I.e., an ICMP
unreachable message or silent drop.";
type zone-action;
default reject;
}
leaf immutable {
description "Indicates if this zone is read-only/system-defined and cannot be modified.";
config false;
type boolean;
}
leaf description {
description "Free-form description of the zone.";
type string;
}
leaf-list interface {
description "List of interfaces assigned to this zone.";
type if:interface-ref;
must "count(/firewall/zone[interface = current()]) <= 1" {
error-message "An interface can only be assigned to one firewall zone";
}
}
leaf-list network {
description "IP networks assigned to this zone.";
type inet:ip-prefix;
}
leaf-list service {
description "Services allowed from this zone to HOST (INPUT chain only).
These define exceptions when action is 'reject' or 'drop'.
Ignored when action is 'accept' (all services allowed).";
type union {
type leafref {
path "../../service/name";
}
type ifw-svc:well-known-service;
}
}
list port-forward {
description "Forward traffic to another port and/or host (DNAT).
Port forwarding rules within a zone apply to traffic matching
the zone's interfaces or networks.";
key "lower proto";
leaf lower {
description "Local port to forward from.";
type inet:port-number;
mandatory true;
}
leaf upper {
description "Upper port when forwarding a range of ports.";
type inet:port-number;
must "../lower <= .";
}
leaf proto {
description "Network protocol to forward.";
type protocol-type;
mandatory true;
}
container to {
description "Destination to forward to.";
leaf addr {
description "Destination IPv4/IPv6 address to forward to.";
type inet:ip-address;
}
leaf port {
description "Destination port to forward to. Defaults to 'lower',
and the upper is then automatically calculated.";
type inet:port-number;
}
}
}
}
list policy {
description "Rules for filtering traffic forwarded between zones (inter-zone).";
ordered-by user;
key "name";
must "count(ingress) > 0 and count(egress) > 0" {
error-message "A policy must have at least one ingress and one egress zone defined";
}
leaf name {
description "Unique identifier (filename) for this policy, e.g., LAN-to-WAN.";
type ident;
}
leaf action {
description "Action for non-matching traffic.
With 'continue' non-matching traffic is allowed to continue to
be processed by the next policy rule, or in the end be dropped.";
type policy-action;
default "reject";
}
leaf immutable {
description "Indicates if this policy is read-only/system-defined and cannot be modified.";
config false;
type boolean;
}
leaf description {
description "Free-form description of this policy's purpose and scope.";
type string;
}
leaf-list ingress {
type ifw:zone-ref;
description "List of zones traffic is entering. Use symbolic 'HOST' or 'ANY' as needed.";
}
leaf-list egress {
description "List of zones traffic is exiting from. Use symbolic 'HOST' or 'ANY' as needed.";
type ifw:zone-ref;
}
leaf masquerade {
description "Enable masquerading (SNAT) for traffic matching this policy.
Matching traffic will have their source IP address changed on egress,
using the IP address of the interface the traffic egresses.";
type boolean;
}
leaf-list service {
description "Services to allow between ingress and egress zones.
Services that are not in this list follow the default policy action. When
it is 'accept', all services are accepted, regardless of this list. When
action is 'reject' or 'drop': these services are accepted, while others are
rejected/dropped. When action is 'continue', traffic not matching the list
is passed on to the next policy.";
type union {
type leafref {
path "../../service/name";
}
type ifw-svc:well-known-service;
}
}
container custom {
description "Custom filters, prioritized over other policy elements.";
list filter {
description "Custom traffic filters with specific matching criteria.
Evaluation order = list order.";
ordered-by user;
key "name";
must "action" {
error-message "Custom filters must have a valid action.";
}
must "not(icmp) or icmp/type" {
error-message "ICMP filters must specify an ICMP type.";
}
leaf name {
description "Unique identifier for this filter within the policy.";
type ident;
}
leaf family {
description "Address family selector.";
type enumeration { enum ipv4; enum ipv6; enum both; }
default both;
}
choice type {
description "Type of traffic to match for this filter.";
case icmp {
container icmp {
leaf type {
description "ICMP type to match.";
type ifw-icmp:type;
}
}
}
}
leaf action {
description "How to handle filter matches.";
// XXX: Different from similar enums because we may add 'mark' later
type enumeration { enum accept; enum drop; enum reject; }
default accept;
}
leaf priority {
// Sorting order as read from firewalld
description "Effective priority of this filter.";
config false;
type int16;
}
}
}
leaf priority {
// Sorting order as read from firewalld
description "Effective priority of this filter.";
config false;
type int16;
}
}
list service {
description "Manage services, human-friendly names of port+protocol pairs.
A service is a collection of port and protocol pairs. Used by the firewall
instead of hard-coding raw port numbers everywhere.";
key "name";
leaf name {
description "Name of the service.";
type ident;
}
leaf description {
description "Free-form description of the service.";
type string;
}
list port {
description "Port, or range of ports, and protocol to match.";
key "lower proto";
leaf lower {
description "Lower port in range.";
type inet:port-number;
}
leaf upper {
description "Upper port in range.";
type inet:port-number;
must "../lower <= .";
}
leaf proto {
description "Layer 4 protocol.";
type protocol-type;
}
}
leaf destination {
type union {
type inet:ip-address;
type inet:ip-prefix;
}
description "Destination IP address/group to match this service to.";
}
}
leaf lockdown {
description "Current state of emergency lockdown mode.";
config false;
type boolean;
}
action lockdown-mode {
description "Emergency lockdown mode blocks all network traffic.
This action is effectively a kill switch for all network
connections, immediately dropping all incoming and outgoing
packets and terminating existing sessions. It is intended for
emergency situations such as active security breaches where
immediate network isolation is required.
WARNING: Activating lockdown mode will sever all remote
connections including SSH sessions. Physical console access
will be required to deactivate lockdown mode and restore
normal network operations.
Implementation uses firewalld panic mode under the hood to
achieve complete traffic blocking at the netfilter level.";
input {
leaf operation {
description "Lockdown operation to perform";
type enumeration {
enum now {
description "Enable lockdown mode immediately - block all traffic";
}
enum cancel {
description "Cancel lockdown mode - restore normal operation";
}
}
mandatory true;
}
}
}
}
}
+1
View File
@@ -0,0 +1 @@
infix-firewall.yang
+58
View File
@@ -142,6 +142,61 @@ int infix_ifaces(kcontext_t *ctx)
return 0;
}
static int firewall_dbus_completion(const char *interface, const char *method, const char *parser)
{
return systemf("gdbus call --system --dest org.fedoraproject.FirewallD1 "
"--object-path /org/fedoraproject/FirewallD1 "
"--method org.fedoraproject.FirewallD1.%s.%s 2>/dev/null "
"| %s", interface, method, parser);
}
/*
* Completion function for firewall zones.
* D-Bus returns variant format: ({'zone1': {...}},)
* Pipeline:
* - sed removes wrapper parentheses
* - tr converts single to double quotes
* - jq extracts keys
*/
int infix_firewall_zones(kcontext_t *ctx)
{
(void)ctx;
return firewall_dbus_completion("zone", "getActiveZones",
"sed 's/^(//; s/,)$//' | sed 's/@as \\[\\]/[]/g' | tr \"'\" '\"' | jq -r 'keys[]' 2>/dev/null");
}
/*
* Completion function for firewall policies.
* D-Bus returns variant format: (['policy1', 'policy2'],)
* Pipeline:
* - sed removes wrapper parentheses
* - tr converts single to double quotes
* - jq extracts array items
*/
int infix_firewall_policies(kcontext_t *ctx)
{
(void)ctx;
return firewall_dbus_completion("policy", "getPolicies",
"sed 's/^(//; s/,)$//' | tr \"'\" '\"' | jq -r '.[]' 2>/dev/null");
}
/*
* Completion function for firewall services.
* D-Bus returns variant format: (['dhcp', 'dns', 'ssh'],)
* Pipeline:
* - sed removes wrapper parentheses
* - tr converts single to double quotes
* - jq extracts array items
*/
int infix_firewall_services(kcontext_t *ctx)
{
(void)ctx;
return systemf("gdbus call --system --dest org.fedoraproject.FirewallD1 "
"--object-path /org/fedoraproject/FirewallD1 "
"--method org.fedoraproject.FirewallD1.listServices 2>/dev/null "
"| sed 's/^(//; s/,)$//' | tr \"'\" '\"' | jq -r '.[]' 2>/dev/null");
}
int infix_copy(kcontext_t *ctx)
{
kpargv_t *pargv = kcontext_pargv(ctx);
@@ -228,6 +283,9 @@ int kplugin_infix_init(kcontext_t *ctx)
kplugin_add_syms(plugin, ksym_new("erase", infix_erase));
kplugin_add_syms(plugin, ksym_new("files", infix_files));
kplugin_add_syms(plugin, ksym_new("ifaces", infix_ifaces));
kplugin_add_syms(plugin, ksym_new("firewall_zones", infix_firewall_zones));
kplugin_add_syms(plugin, ksym_new("firewall_policies", infix_firewall_policies));
kplugin_add_syms(plugin, ksym_new("firewall_services", infix_firewall_services));
kplugin_add_syms(plugin, ksym_new("shell", infix_shell));
return 0;
+77
View File
@@ -113,6 +113,27 @@
<ACTION sym="STRING"/>
</PTYPE>
<PTYPE name="FIREWALL_ZONES">
<COMPL>
<ACTION sym="firewall_zones@infix"/>
</COMPL>
<ACTION sym="STRING"/>
</PTYPE>
<PTYPE name="FIREWALL_POLICIES">
<COMPL>
<ACTION sym="firewall_policies@infix"/>
</COMPL>
<ACTION sym="STRING"/>
</PTYPE>
<PTYPE name="FIREWALL_SERVICES">
<COMPL>
<ACTION sym="firewall_services@infix"/>
</COMPL>
<ACTION sym="STRING"/>
</PTYPE>
<VIEW name="main">
<HOTKEY key="^D" cmd="exit"/>
@@ -490,6 +511,43 @@
<COMMAND name="startup-config" help="Show startup-config">
<ACTION sym="script" in="tty" out="tty" interrupt="true">jq -C . /cfg/startup-config.cfg |pager</ACTION>
</COMMAND>
<COMMAND name="firewall" help="Show firewall status and configuration">
<SWITCH name="optional" min="0" max="1">
<COMMAND name="log" help="Show firewall log (jumps to end), alias to 'show log firewall.log'">
<ACTION sym="script" in="tty" out="tty" interrupt="true">
doas -u $USER cat /log/firewall.log |pager +G
</ACTION>
</COMMAND>
<COMMAND name="zone" help="Show firewall zones">
<SWITCH name="optional" min="0">
<PARAM name="name" ptype="/FIREWALL_ZONES" help="Zone name"/>
</SWITCH>
<ACTION sym="script" in="tty" out="tty" interrupt="true">
sysrepocfg -X -d operational -x /infix-firewall:firewall -f json -t 60 | /usr/libexec/statd/cli-pretty show-firewall-zone "$KLISH_PARAM_name" |pager
</ACTION>
</COMMAND>
<COMMAND name="policy" help="Show firewall policies">
<SWITCH name="optional" min="0">
<PARAM name="name" ptype="/FIREWALL_POLICIES" help="Policy name"/>
</SWITCH>
<ACTION sym="script" in="tty" out="tty" interrupt="true">
sysrepocfg -X -d operational -x /infix-firewall:firewall -f json -t 60 | /usr/libexec/statd/cli-pretty show-firewall-policy "$KLISH_PARAM_name" |pager
</ACTION>
</COMMAND>
<COMMAND name="service" help="Show firewall services">
<SWITCH name="optional" min="0">
<PARAM name="name" ptype="/FIREWALL_SERVICES" help="Service name"/>
</SWITCH>
<ACTION sym="script" in="tty" out="tty" interrupt="true">
sysrepocfg -X -d operational -x /infix-firewall:firewall -f json -t 60 | /usr/libexec/statd/cli-pretty show-firewall-service "$KLISH_PARAM_name" |pager
</ACTION>
</COMMAND>
</SWITCH>
<ACTION sym="script" in="tty" out="tty" interrupt="true">
sysrepocfg -X -d operational -x /infix-firewall:firewall -f json -t 60 | /usr/libexec/statd/cli-pretty show-firewall |pager
</ACTION>
</COMMAND>
</COMMAND>
<COMMAND name="factory-reset" help="Restore the system to factory default state">
@@ -499,6 +557,25 @@
<ACTION sym="srp_rpc@sysrepo">/ietf-factory-default:factory-reset</ACTION>
</COMMAND>
<COMMAND name="firewall" help="Control the firewall" mode="switch">
<COMMAND name="lockdown" help="Emergency lockdown mode">
<PARAM name="operation" ptype="/STRING" help="Lockdown commands.">
<COMPL>
<ACTION sym="printl">now</ACTION>
<ACTION sym="printl">cancel</ACTION>
</COMPL>
</PARAM>
<ACTION sym="script" in="tty" out="tty" interrupt="true">
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
</ACTION>
<ACTION sym="srp_rpc@sysrepo">/infix-firewall:firewall/lockdown-mode</ACTION>
</COMMAND>
</COMMAND>
<COMMAND name="follow" help="Monitor a log file, use Ctrl-C to abort">
<PARAM name="fn" ptype="/LOGFILES" help="Optional log file to monitor, default: syslog"/>
<ACTION sym="script" in="tty" out="tty" interrupt="true">
+40 -9
View File
@@ -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_start(ap, fmt);
vsnprintf(xpath, len, fmt, ap);
va_end(ap);
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);
rc = set_vaitem(session, val, opts, fmt, ap);
va_end(ap);
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, ...)
{
+2
View File
@@ -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)));
File diff suppressed because it is too large Load Diff
+3
View File
@@ -78,6 +78,9 @@ def main():
elif args.model == 'ieee802-dot1ab-lldp':
from . import infix_lldp
yang_data = infix_lldp.operational()
elif args.model == 'infix-firewall':
from . import infix_firewall
yang_data = infix_firewall.operational()
else:
common.LOG.warning("Unsupported model %s", args.model)
sys.exit(1)
+362
View File
@@ -0,0 +1,362 @@
#!/usr/bin/env python3
"""
Collect operational data for infix-firewall.yang from firewalld using D-Bus,
for the full API, see:
gdbus introspect --system --dest org.fedoraproject.FirewallD1 \
--object-path /org/fedoraproject/FirewallD1
"""
import dbus
import re
from . import common
def get_interface(interface="org.fedoraproject.FirewallD1"):
try:
bus = dbus.SystemBus()
obj = bus.get_object("org.fedoraproject.FirewallD1",
"/org/fedoraproject/FirewallD1")
return dbus.Interface(obj, dbus_interface=interface)
except dbus.exceptions.DBusException as e:
common.LOG.warning("Failed to connect to firewalld D-Bus: %s", e)
return None
def get_zone_data(fw, name):
"""
$ gdbus call --system --dest org.fedoraproject.FirewallD1 \
--object-path /org/fedoraproject/FirewallD1 \
--method org.fedoraproject.FirewallD1.zone.getForwardPorts \
external
([['443', 'tcp', '443', '192.168.2.10']],)
"""
try:
settings = fw.getZoneSettings2(name)
target = settings.get('target', 'default')
action = {
"%%REJECT%%": "reject",
"REJECT": "reject",
"ACCEPT": "accept",
"DROP": "drop",
"default": "accept"
}
short = settings.get('short', '')
immutable = False
if short and "(immutable)" in short:
# Remove (immutable), added by us to set ⚷ symbol in output
short = short.replace("(immutable)", "").strip()
immutable = True
elif not short:
short = ""
zone = {
"name": name,
"short": short,
"immutable": immutable,
"description": settings.get('description', 0),
"interface": list(settings.get('interfaces', [])),
"network": list(settings.get('sources', [])),
"action": action.get(target, "accept"),
"service": list(settings.get('services', []))
}
# Handle port forwarding from zone
port_forwards = []
forwards = settings.get('forward_ports', [])
for fwd in forwards:
try:
if len(fwd) >= 4:
port, protocol, toport, toaddr = fwd[:4] # Fixed field order!
# Handle port ranges: port can be "80" or "8000-8080"
if '-' in str(port):
port_lower, port_upper = str(port).split('-', 1)
fwd_data = {
'lower': int(port_lower),
'upper': int(port_upper),
'proto': str(protocol),
'to': {
'addr': str(toaddr)
}
}
else:
fwd_data = {
'lower': int(port),
'proto': str(protocol),
'to': {
'addr': str(toaddr)
}
}
# Handle destination port - only store lower port, upper calculated by C code
if toport and str(toport).strip():
toport_str = str(toport).strip()
# Skip if toport looks like an IP address instead of port
if '.' not in toport_str and ':' not in toport_str:
fwd_data['to']['port'] = int(toport_str)
else:
# If toport looks like IP, use the same port as source lower
fwd_data['to']['port'] = fwd_data['lower']
else:
# No destination port specified, use same as source lower
fwd_data['to']['port'] = fwd_data['lower']
port_forwards.append(fwd_data)
except (ValueError, IndexError, TypeError) as e:
common.LOG.warning("Invalid port forward rule in zone %s: %s", name, e)
continue
if port_forwards:
zone["port-forward"] = port_forwards
return zone
except Exception as e:
common.LOG.warning("Failed querying zone %s via D-Bus: %s", name, e)
return None
def get_zones(fw):
"""Get only active zones (loaded in kernel) instead of all zones"""
zones = []
try:
fwz = get_interface("org.fedoraproject.FirewallD1.zone")
if not fwz:
return zones
active_zones = fwz.getActiveZones()
for name, zone_info in active_zones.items():
zone_data = get_zone_data(fwz, name)
if zone_data:
zone_data['interface'] = list(zone_info.get('interfaces', []))
zone_data['network'] = list(zone_info.get('sources', []))
zones.append(zone_data)
except Exception as e:
common.LOG.warning("Failed querying zones: %s", e)
return zones
def get_policy_data(fw, name):
try:
settings = fw.getPolicySettings(name)
policy = {
"name": name,
"action": "reject",
"priority": 32767,
"ingress": [],
"egress": []
}
target = settings.get('target', 'CONTINUE')
action = {
"CONTINUE": "continue",
"ACCEPT": "accept",
"REJECT": "reject",
"DROP": "drop"
}
policy["action"] = action.get(target, "reject")
priority = settings.get('priority', 32767)
if isinstance(priority, int):
policy["priority"] = priority
description = settings.get('description', '')
if description:
policy["description"] = description
short = settings.get('short', '')
policy["immutable"] = bool(short and "(immutable)" in short)
ingress = settings.get('ingress_zones', [])
if ingress:
policy["ingress"] = list(ingress)
egress = settings.get('egress_zones', [])
if egress:
policy["egress"] = list(egress)
services = settings.get('services', [])
if services:
policy["service"] = list(services)
policy["masquerade"] = bool(settings.get('masquerade', 0))
# Handle custom filters from rich_rules
custom_filters = []
rich_rules = settings.get('rich_rules', [])
for rule in rich_rules:
# Extract family (default to both if not specified)
family = "both"
if 'family="ipv4"' in rule:
family = "ipv4"
elif 'family="ipv6"' in rule:
family = "ipv6"
icmp_type = None
action = None
prio = -1
if 'priority' in rule:
prio_match = re.search(r'.*priority=([^ ]+)', rule)
if prio_match:
val = prio_match.group(1)
if isinstance(val, int):
prio = val
if 'icmp-type' in rule and 'name=' in rule:
name_match = re.search(r'.*name="([^"]+)"', rule)
if name_match:
icmp_type = name_match.group(1)
action = "accept"
if ' drop' in rule:
action = "drop"
elif ' reject' in rule:
action = "reject"
elif 'icmp-block' in rule and 'name=' in rule:
name_match = re.search(r'.*name="([^"]+)"', rule)
if name_match:
icmp_type = name_match.group(1)
action = "reject"
if icmp_type and action:
filter_entry = {
"name": f"icmp-{icmp_type}",
"priority": prio,
"family": family,
"action": action,
"icmp": {
"type": icmp_type
}
}
custom_filters.append(filter_entry)
if custom_filters:
policy["custom"] = {
"filter": custom_filters
}
return policy
except Exception as e:
common.LOG.warning("Failed querying policy %s via D-Bus: %s", name, e)
return None
def get_policies(fw):
policies = []
try:
fwp = get_interface("org.fedoraproject.FirewallD1.policy")
if not fwp:
return policies
for name in fwp.getPolicies():
data = get_policy_data(fwp, name)
if data:
policies.append(data)
except Exception as e:
common.LOG.warning("Failed querying policies: %s", e)
# Add implicit drop/reject policy as the last rule
implicit_policy = {
"name": "default-drop",
"description": "Default deny rule - drops all unmatched traffic",
"action": "drop",
"priority": 32767, # Highest priority number (lowest precedence)
"ingress": ["ANY"],
"egress": ["ANY"],
"immutable": True
}
policies.append(implicit_policy)
return policies
def get_service_data(fw, name):
try:
settings = fw.getServiceSettings2(name)
service = {
"name": name,
"port": []
}
description = settings.get('description', '')
if description:
service["description"] = description
ports = settings.get('ports', [])
for port_info in ports:
if len(port_info) >= 2:
port, protocol = port_info[:2]
port_data = {'proto': protocol}
if '-' in str(port):
lower, upper = str(port).split('-', 1)
port_data['lower'] = int(lower)
port_data['upper'] = int(upper)
else:
port_data['lower'] = int(port)
service["port"].append(port_data)
return service
except Exception as e:
common.LOG.warning("Failed querying service %s via D-Bus: %s", name, e)
return None
def get_services(fw):
services = []
try:
for name in fw.listServices():
data = get_service_data(fw, name)
if data:
services.append(data)
except Exception as e:
common.LOG.warning("Failed querying services: %s", e)
return services
def operational():
try:
fw = get_interface()
if not fw:
return {}
except Exception as e:
common.LOG.warning("Failed checking firewalld state: %s", e)
return {}
data = {
"infix-firewall:firewall": {
"default": fw.getDefaultZone(),
"logging": fw.getLogDenied(),
"lockdown": bool(fw.queryPanicMode())
}
}
zones = get_zones(fw)
if zones:
data["infix-firewall:firewall"]["zone"] = zones
policies = get_policies(fw)
if policies:
data["infix-firewall:firewall"]["policy"] = policies
services = get_services(fw)
if services:
data["infix-firewall:firewall"]["service"] = services
return data
+3
View File
@@ -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;
+1 -1
View File
@@ -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")
+3
View File
@@ -34,6 +34,9 @@
- name: "IETF Routing"
suite: ietf_routing/all.yaml
- name: "Infix Firewall"
suite: infix_firewall/all.yaml
- name: "Infix Containers"
suite: infix_containers/all.yaml
+22
View File
@@ -0,0 +1,22 @@
:testgroup:
== infix-firewall
<<<
include::basic/Readme.adoc[]
<<<
include::lan-wan/Readme.adoc[]
<<<
include::wan-dmz-lan/Readme.adoc[]
<<<
include::ipv6-lan-wan/Readme.adoc[]
<<<
include::ipv6-zone-migration/Readme.adoc[]
+15
View File
@@ -0,0 +1,15 @@
---
- name: Basic Firewall for End Devices
case: basic/test.py
- name: LAN-WAN Firewall with Masquerading
case: lan-wan/test.py
- name: WAN-DMZ-LAN Firewall with Port Forwarding
case: wan-dmz-lan/test.py
- name: IPv6 LAN-WAN Firewall
case: ipv6-lan-wan/test.py
- name: IPv6 Zone Migration with Custom Services
case: ipv6-zone-migration/test.py
+1
View File
@@ -0,0 +1 @@
test.adoc
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

+31
View File
@@ -0,0 +1,31 @@
=== Basic Firewall for End Devices
ifdef::topdoc[:imagesdir: {topdoc}../../test/case/infix_firewall/basic]
==== Description
Firewall configuration suitable for end devices on untrusted networks.
image::basic.svg[align=center, scaledwidth=50%]
- Single zone configuration, "public", with action=drop
- Allowed services: SSH (port 22), DHCPv6-client, mySSH (custom, port 222)
- All other ports (HTTP, HTTPS, Telnet, etc.) blocked
- Verifies unused interfaces automatically assigned to default zone
==== Topology
image::topology.svg[Basic Firewall for End Devices topology, align=center, scaledwidth=75%]
==== Sequence
. Set up topology and attach to target
. Configure basic end-device firewall
. Verify unused interface assigned to default zone
. Verify ICMP is dropped
. Verify ICMPv6 is dropped
. Verify SSH service is allowed
. Verify custom mySSH service is allowed
. Verify other ports are blocked
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""Basic Firewall for End Devices
Firewall configuration suitable for end devices on untrusted networks.
image::basic.svg[align=center, scaledwidth=50%]
- Single zone configuration, "public", with action=drop
- Allowed services: SSH (port 22), DHCPv6-client, mySSH (custom, port 222)
- All other ports (HTTP, HTTPS, Telnet, etc.) blocked
- Check that unused interfaces are automatically assigned to default zone
"""
import time
import infamy
from infamy.util import until
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")
_, unused_if = env.ltop.xlate("target", "unused")
_, host_data = env.ltop.xlate("host", "data")
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
}]
}
}
]
}
})
target.put_config_dict("infix-firewall", {
"firewall": {
"default": "public",
"logging": "all",
"service": [{
"name": "mySSH",
"port": [{
"lower": 222,
"proto": "tcp"
}]
}, {
"name": "http",
"port": [{
"lower": 8080,
"proto": "tcp"
}]
}],
"zone": [{
"name": "mgmt",
"description": "Management network - for test automation",
"action": "accept",
"interface": [mgmt_if],
"service": ["ssh", "netconf", "restconf"]
}, {
"name": "public",
"description": "Public untrusted network",
"action": "drop",
"interface": [data_if],
"service": ["ssh", "dhcpv6-client", "mySSH", "http"]
}]
}
})
# Wait for configuration to be activated
infamy.Firewall.wait_for_operational(target, {
"public": {"action": "drop"},
"mgmt": {"action": "accept"}
})
# Verify firewall operational state
data = target.get_data("/infix-firewall:firewall")
fw = data["firewall"]
assert fw["default"] == "public"
services = {svc["name"]: svc for svc in fw.get("service", [])}
assert "mySSH" in services, "Custom service mySSH not found"
custom_service = services["mySSH"]
assert len(custom_service["port"]) == 1
port_entry = next(iter(custom_service["port"]))
assert port_entry["proto"] == "tcp"
assert int(port_entry["lower"]) == 222
assert "http" in services, "HTTP service override not found"
http_service = services["http"]
assert len(http_service["port"]) == 1
port_entry = next(iter(http_service["port"]))
assert port_entry["proto"] == "tcp"
assert int(port_entry["lower"]) == 8080
zones = {zone["name"]: zone for zone in fw["zone"]}
assert "public" in zones, "Public zone not found in configuration"
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"]
assert "mySSH" in public_zone["service"]
assert "http" in public_zone["service"]
with test.step("Verify unused interface assigned to default zone"):
data = target.get_data("/infix-firewall:firewall")
fw = data["firewall"]
assert fw["default"] == "public", "Default zone should be 'public'"
zones = {zone["name"]: zone for zone in fw["zone"]}
public_zone = zones["public"]
assert unused_if in public_zone["interface"], \
f"Unused interface {unused_if} should be in default zone 'public', got interfaces: {public_zone['interface']}"
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_reach("fe80::1%iface", timeout=2)
with test.step("Verify SSH service 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 custom mySSH service is allowed"):
scanner = infamy.PortScanner(ns)
myssh_result = scanner.scan_port(TARGET_IP, 222, timeout=2)
assert myssh_result["status"] in ["open", "closed"], \
f"mySSH port 222 should be allowed, got: {myssh_result['status']}"
with test.step("Verify HTTP service override (8080 allowed, 80 blocked)"):
scanner = infamy.PortScanner(ns)
# Custom HTTP on port 8080 should be allowed
http_custom_result = scanner.scan_port(TARGET_IP, 8080, timeout=2)
assert http_custom_result["status"] in ["open", "closed"], \
f"Custom HTTP port 8080 should be allowed, got: {http_custom_result['status']}"
# Built-in HTTP on port 80 should be blocked (filtered)
http_builtin_result = scanner.scan_port(TARGET_IP, 80, timeout=2)
assert http_builtin_result["status"] == "filtered", \
f"Built-in HTTP port 80 should be blocked, got: {http_builtin_result['status']}"
with test.step("Verify other ports are blocked"):
firewall = infamy.Firewall(ns, None)
allowed = [22, 222, 8080]
ok, open_ports, filtered = \
firewall.verify_blocked(TARGET_IP, exempt=allowed)
if not ok:
if open_ports:
print(f"Unexpected open ports: {', '.join(open_ports)}")
if filtered:
print(f"Unexpected, filtered ports: {', '.join(filtered)}")
test.fail()
test.succeed()
@@ -0,0 +1,30 @@
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> mgmt | <data> data }",
pos="1,1!",
requires="controller"
];
target [
label="{ <mgmt> mgmt | <data> data | <unused> unused } | target",
pos="3,1!",
requires="infix",
];
dummy [
label="{ <link> link } | dummy",
pos="5,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"]
target:unused -- dummy:link [color="gray", style="dashed"]
}
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Title: 1x3 Pages: 1 -->
<svg width="440pt" height="78pt"
viewBox="0.00 0.00 440.03 78.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 74)">
<title>1x3</title>
<polygon fill="white" stroke="transparent" points="-4,4 -4,-74 436.03,-74 436.03,4 -4,4"/>
<!-- host -->
<g id="node1" class="node">
<title>host</title>
<polygon fill="none" stroke="black" points="0,-12 0,-58 100,-58 100,-12 0,-12"/>
<text text-anchor="middle" x="25" y="-31.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">host</text>
<polyline fill="none" stroke="black" points="50,-12 50,-58 "/>
<text text-anchor="middle" x="75" y="-42.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
<polyline fill="none" stroke="black" points="50,-35 100,-35 "/>
<text text-anchor="middle" x="75" y="-19.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">data</text>
</g>
<!-- target -->
<g id="node2" class="node">
<title>target</title>
<polygon fill="none" stroke="black" points="300.03,-0.5 300.03,-69.5 432.03,-69.5 432.03,-0.5 300.03,-0.5"/>
<text text-anchor="middle" x="333.03" y="-54.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
<polyline fill="none" stroke="black" points="300.03,-46.5 366.03,-46.5 "/>
<text text-anchor="middle" x="333.03" y="-31.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">data</text>
<polyline fill="none" stroke="black" points="300.03,-23.5 366.03,-23.5 "/>
<text text-anchor="middle" x="333.03" y="-8.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">unused</text>
<polyline fill="none" stroke="black" points="366.03,-0.5 366.03,-69.5 "/>
<text text-anchor="middle" x="399.03" y="-31.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">target</text>
</g>
<!-- host&#45;&#45;target -->
<g id="edge1" class="edge">
<title>host:mgmt&#45;&#45;target:mgmt</title>
<path fill="none" stroke="lightgray" stroke-width="2" d="M100,-47C100,-47 300.03,-58 300.03,-58"/>
</g>
<!-- host&#45;&#45;target -->
<g id="edge2" class="edge">
<title>host:data&#45;&#45;target:data</title>
<path fill="none" stroke="black" stroke-width="2" d="M100,-23C100,-23 300.03,-35 300.03,-35"/>
<text text-anchor="middle" x="159" y="-26.8" font-family="DejaVu Serif, Book" font-size="14.00">192.168.1.42/24</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+1
View File
@@ -0,0 +1 @@
test.adoc
+1
View File
@@ -0,0 +1 @@
../lan-wan/lan-wan.svg
@@ -0,0 +1,31 @@
=== IPv6 LAN-WAN Firewall
ifdef::topdoc[:imagesdir: {topdoc}../../test/case/infix_firewall/ipv6-lan-wan]
==== Description
IPv6 version of the typical home/office router scenario where the DUT acts as
a gateway with LAN-to-WAN traffic forwarding and IPv6 prefix delegation.
image::lan-wan.svg[align=center, scaledwidth=50%]
- DUT/Gateway with IPv6 firewall and forwarding
- Test host has two interfaces: a LAN-side and a WAN-side (Internet)
- Test host's LAN interface acts as an IPv6 client behind the router
- Test host's WAN interface acts as an IPv6 Internet server/destination
- Demonstrates IPv6 policy-based forwarding between zones
==== Topology
image::topology.svg[IPv6 LAN-WAN Firewall topology, align=center, scaledwidth=75%]
==== Sequence
. Set up topology and attach to gateway
. Configure gateway with firewall and forwarding
. Test connectivity to gateway
. Test LAN-to-WAN forwarding
. Test WAN-to-LAN blocking
. Verify LAN services accessibility
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""IPv6 LAN-WAN Firewall
IPv6 version of the typical home/office router scenario where the DUT acts as
a gateway with LAN-to-WAN traffic forwarding and IPv6 prefix delegation.
image::lan-wan.svg[align=center, scaledwidth=50%]
- DUT/Gateway with IPv6 firewall and forwarding
- Test host has two interfaces: a LAN-side and a WAN-side (Internet)
- Test host's LAN interface acts as an IPv6 client behind the router
- Test host's WAN interface acts as an IPv6 Internet server/destination
- Demonstrates IPv6 policy-based forwarding between zones
"""
import time
import infamy
from infamy.util import until
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 = "fd01:db8:1::/64"
LAN_ROUTER_IP = "fd01:db8:1::1" # Router's LAN interface
LAN_CLIENT_IP = "fd01:db8:1::100" # Client on LAN side
WAN_NET = "2001:db8:2::/64" # RFC 3849 documentation prefix
WAN_ROUTER_IP = "2001:db8:2::1" # Router's WAN interface
WAN_SERVER_IP = "2001:db8:2::100" # Server on WAN side
with test.step("Configure gateway with firewall and forwarding"):
gateway.put_config_dicts({
"ietf-interfaces": {
"interfaces": {
"interface": [{
"name": lan_if,
"enabled": True,
"ipv6": {
"enabled": True,
"forwarding": True,
"address": [{
"ip": LAN_ROUTER_IP,
"prefix-length": 64
}]
}
}, {
"name": wan_if,
"enabled": True,
"ipv6": {
"enabled": True,
"forwarding": True,
"address": [{
"ip": WAN_ROUTER_IP,
"prefix-length": 64
}]
}
}]
}
},
"infix-firewall": {
"firewall": {
"default": "wan",
"logging": "all",
"zone": [{
"name": "lan",
"description": "Internal LAN network - trusted",
"action": "accept",
"interface": [lan_if, mgmt_if],
"service": ["ssh", "dhcpv6", "dns"]
}, {
"name": "wan",
"description": "External WAN interface - untrusted",
"action": "drop",
"interface": [wan_if]
}],
"policy": [{
"name": "lan-to-wan",
"description": "Allow LAN to WAN traffic",
"ingress": ["lan"],
"egress": ["wan"],
"action": "accept"
}]
}
}
})
# Wait for configuration to be activated
infamy.Firewall.wait_for_operational(gateway, {
"lan": {"action": "accept"},
"wan": {"action": "drop"}
})
# Verify firewall operational state
data = gateway.get_data("/infix-firewall:firewall")
fw = data["firewall"]
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 exists
policies = {p["name"]: p for p in fw.get("policy", [])}
assert "lan-to-wan" in policies
policy = policies["lan-to-wan"]
assert "lan" in policy["ingress"]
assert "wan" in policy["egress"]
assert policy["action"] == "accept"
with infamy.IsolatedMacVlan(host_lan) as lan_ns:
lan_ns.addip(LAN_CLIENT_IP, prefix_length=64, proto="ipv6")
lan_ns.addroute("default", LAN_ROUTER_IP, proto="ipv6")
with infamy.IsolatedMacVlan(host_wan) as wan_ns:
wan_ns.addip(WAN_SERVER_IP, prefix_length=64, proto="ipv6")
wan_ns.addroute("default", WAN_ROUTER_IP, proto="ipv6")
with test.step("Test connectivity to gateway"):
lan_ns.must_reach(LAN_ROUTER_IP, timeout=5)
wan_ns.must_not_reach(WAN_ROUTER_IP, timeout=5)
with test.step("Test LAN-to-WAN forwarding"):
lan_ns.must_reach(WAN_SERVER_IP, timeout=10)
with test.step("Test WAN-to-LAN blocking"):
wan_ns.must_not_reach(LAN_CLIENT_IP, timeout=5)
with test.step("Verify LAN services accessibility"):
firewall_lan = infamy.Firewall(lan_ns, None)
svc = [
(22, "tcp", "ssh"),
(53, "tcp", "dns")
]
ok, ports = firewall_lan.verify_allowed(LAN_ROUTER_IP, svc)
if not ok:
print(f" ⚠ Some LAN services are filtered: {', '.join(ports)}")
test.fail()
test.succeed()
@@ -0,0 +1,24 @@
graph "lan-wan-v6" {
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> mgmt | <lan> lan | <wan> wan }",
pos="1,1!",
requires="controller"
];
gateway [
label="{ <mgmt> mgmt | <lan> lan | <wan> wan } | gateway",
pos="3,1!",
requires="infix",
];
host:mgmt -- gateway:mgmt [requires="mgmt", color="lightgray"]
host:lan -- gateway:lan [color=green, fontcolor=green, taillabel="fd01:db8:1::/64"]
host:wan -- gateway:wan [color=red, fontcolor=red, taillabel="2001:db8:2::/64"]
}
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Title: lan&#45;wan&#45;v6 Pages: 1 -->
<svg width="432pt" height="78pt"
viewBox="0.00 0.00 432.03 78.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 74)">
<title>lan&#45;wan&#45;v6</title>
<polygon fill="white" stroke="transparent" points="-4,4 -4,-74 428.03,-74 428.03,4 -4,4"/>
<!-- host -->
<g id="node1" class="node">
<title>host</title>
<polygon fill="none" stroke="black" points="0,-0.5 0,-69.5 100,-69.5 100,-0.5 0,-0.5"/>
<text text-anchor="middle" x="25" y="-31.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">host</text>
<polyline fill="none" stroke="black" points="50,-0.5 50,-69.5 "/>
<text text-anchor="middle" x="75" y="-54.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
<polyline fill="none" stroke="black" points="50,-46.5 100,-46.5 "/>
<text text-anchor="middle" x="75" y="-31.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">lan</text>
<polyline fill="none" stroke="black" points="50,-23.5 100,-23.5 "/>
<text text-anchor="middle" x="75" y="-8.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">wan</text>
</g>
<!-- gateway -->
<g id="node2" class="node">
<title>gateway</title>
<polygon fill="none" stroke="black" points="300.03,-0.5 300.03,-69.5 424.03,-69.5 424.03,-0.5 300.03,-0.5"/>
<text text-anchor="middle" x="325.03" y="-54.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
<polyline fill="none" stroke="black" points="300.03,-46.5 350.03,-46.5 "/>
<text text-anchor="middle" x="325.03" y="-31.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">lan</text>
<polyline fill="none" stroke="black" points="300.03,-23.5 350.03,-23.5 "/>
<text text-anchor="middle" x="325.03" y="-8.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">wan</text>
<polyline fill="none" stroke="black" points="350.03,-0.5 350.03,-69.5 "/>
<text text-anchor="middle" x="387.03" y="-31.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">gateway</text>
</g>
<!-- host&#45;&#45;gateway -->
<g id="edge1" class="edge">
<title>host:mgmt&#45;&#45;gateway:mgmt</title>
<path fill="none" stroke="lightgray" stroke-width="2" d="M100,-58C100,-58 300.03,-58 300.03,-58"/>
</g>
<!-- host&#45;&#45;gateway -->
<g id="edge2" class="edge">
<title>host:lan&#45;&#45;gateway:lan</title>
<path fill="none" stroke="green" stroke-width="2" d="M100,-35C100,-35 300.03,-35 300.03,-35"/>
<text text-anchor="middle" x="154.5" y="-38.8" font-family="DejaVu Serif, Book" font-size="14.00" fill="green">fd01:db8:1::/64</text>
</g>
<!-- host&#45;&#45;gateway -->
<g id="edge3" class="edge">
<title>host:wan&#45;&#45;gateway:wan</title>
<path fill="none" stroke="red" stroke-width="2" d="M100,-12C100,-12 300.03,-12 300.03,-12"/>
<text text-anchor="middle" x="156.5" y="-15.8" font-family="DejaVu Serif, Book" font-size="14.00" fill="red">2001:db8:2::/64</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -0,0 +1 @@
test.adoc
@@ -0,0 +1,31 @@
=== IPv6 Zone Migration with Custom Services
ifdef::topdoc[:imagesdir: {topdoc}../../test/case/infix_firewall/ipv6-zone-migration]
==== Description
This test verifies that firewall rules work consistently across IPv4/IPv6
protocols and that interfaces can be moved between zones without breaking
active connections.
- Requires DUT with at least 2 data interfaces supporting IPv6
- Test host must support dual-stack IPv4/IPv6 configuration
- Custom service ports (8080/tcp) should be available for testing
==== Topology
image::topology.svg[IPv6 Zone Migration with Custom Services topology, align=center, scaledwidth=75%]
==== Sequence
. Set up topology and attach to target
. Configure dual-stack interfaces and initial firewall
. Verify initial zone configuration and custom service
. Verify IPv4/IPv6 connectivity and custom service restrictions
. Verify IPv6 custom service functionality
. Perform dynamic zone migration
. Verify connectivity after zone migration
. Verify custom service from migrated interface
. Verify operational state reflects zone changes
+240
View File
@@ -0,0 +1,240 @@
#!/usr/bin/env python3
"""IPv6 Zone Migration with Custom Service
This test verifies that firewall rules work consistently across IPv4/IPv6
protocols and that interfaces can be moved between zones without breaking
active connections.
- Requires DUT with at least 2 data interfaces supporting IPv6
- Test host must support dual-stack IPv4/IPv6 configuration
- Custom service ports (8080/tcp) should be available for testing
"""
import time
import infamy
from infamy.util import until
with infamy.Test() as test:
with test.step("Set up topology and attach to target"):
env = infamy.Env()
target = env.attach("target", "mgmt")
_, data1_if = env.ltop.xlate("target", "data1")
_, data2_if = env.ltop.xlate("target", "data2")
_, mgmt_if = env.ltop.xlate("target", "mgmt")
_, host_data1 = env.ltop.xlate("host", "data1")
_, host_data2 = env.ltop.xlate("host", "data2")
# IPv4 addressing
DATA1_NET_V4 = "10.1.1.0/24"
DATA1_TARGET_V4 = "10.1.1.1"
DATA1_HOST_V4 = "10.1.1.100"
DATA2_NET_V4 = "10.2.2.0/24"
DATA2_TARGET_V4 = "10.2.2.1"
DATA2_HOST_V4 = "10.2.2.100"
# IPv6 addressing
DATA1_NET_V6 = "fd01:1:1::/64"
DATA1_TARGET_V6 = "fd01:1:1::1"
DATA1_HOST_V6 = "fd01:1:1::100"
DATA2_NET_V6 = "fd02:2:2::/64"
DATA2_TARGET_V6 = "fd02:2:2::1"
DATA2_HOST_V6 = "fd02:2:2::100"
# Custom service port
CUSTOM_PORT = 8080
with test.step("Configure dual-stack interfaces and initial firewall"):
target.put_config_dicts({
"ietf-interfaces": {
"interfaces": {
"interface": [{
"name": data1_if,
"enabled": True,
"ipv4": {
"address": [{
"ip": DATA1_TARGET_V4,
"prefix-length": 24
}]
},
"ipv6": {
"enabled": True,
"address": [{
"ip": DATA1_TARGET_V6,
"prefix-length": 64
}]
}
}, {
"name": data2_if,
"enabled": True,
"ipv4": {
"address": [{
"ip": DATA2_TARGET_V4,
"prefix-length": 24
}]
},
"ipv6": {
"enabled": True,
"address": [{
"ip": DATA2_TARGET_V6,
"prefix-length": 64
}]
}
}]
}
},
"infix-firewall": {
"firewall": {
"default": "untrusted",
"logging": "all",
"service": [{
"name": "myapp",
"port": [{
"lower": CUSTOM_PORT,
"proto": "tcp"
}]
}],
"zone": [{
"name": "mgmt",
"description": "Management network",
"action": "accept",
"interface": [mgmt_if],
"service": ["ssh", "netconf", "restconf"]
}, {
"name": "untrusted",
"description": "Untrusted zone",
"action": "accept",
"interface": [data1_if]
}, {
"name": "trusted",
"description": "Trusted zone",
"action": "accept",
"interface": [data2_if],
"service": ["ssh", "myapp"]
}]
}
}
})
# Wait for configuration to be activated
infamy.Firewall.wait_for_operational(target, {
"untrusted": {"action": "accept"},
"trusted": {"action": "accept"},
"mgmt": {"action": "accept"}
})
with test.step("Verify initial zone configuration and custom service"):
# Verify operational state matches expected configuration
data = target.get_data("/infix-firewall:firewall")
fw = data["firewall"]
assert fw["default"] == "untrusted"
zones = {zone["name"]: zone for zone in fw["zone"]}
services = {svc["name"]: svc for svc in fw.get("service", [])}
# Verify custom service exists
assert "myapp" in services, "Custom service myapp not found"
custom_service = services["myapp"]
assert len(custom_service["port"]) == 1
port_entry = next(iter(custom_service["port"]))
assert port_entry["proto"] == "tcp"
assert int(port_entry["lower"]) == CUSTOM_PORT
# Verify zone assignments
untrusted_zone = zones["untrusted"]
trusted_zone = zones["trusted"]
assert data1_if in untrusted_zone["interface"]
assert data2_if in trusted_zone["interface"]
# Check services safely - they may not exist in operational data if empty
trusted_services = trusted_zone.get("service", [])
untrusted_services = untrusted_zone.get("service", [])
assert "myapp" in trusted_services, f"Custom service should be in trusted zone, got: {trusted_services}"
assert "myapp" not in untrusted_services, f"Custom service should not be in untrusted zone, got: {untrusted_services}"
with infamy.IsolatedMacVlan(host_data1) as ns1:
ns1.addip(DATA1_HOST_V4, prefix_length=24, proto="ipv4")
ns1.addip(DATA1_HOST_V6, prefix_length=64, proto="ipv6")
with infamy.IsolatedMacVlan(host_data2) as ns2:
ns2.addip(DATA2_HOST_V4, prefix_length=24, proto="ipv4")
ns2.addip(DATA2_HOST_V6, prefix_length=64, proto="ipv6")
with test.step("Verify IPv4/IPv6 connectivity and custom service restrictions"):
# print(f"Testing IPv4 connectivity: {DATA1_HOST_V4} -> {DATA1_TARGET_V4}")
# print(f"Testing IPv4 connectivity: {DATA2_HOST_V4} -> {DATA2_TARGET_V4}")
ns1.must_reach(DATA1_TARGET_V4, timeout=5)
ns2.must_reach(DATA2_TARGET_V4, timeout=5)
# print(f"Testing IPv6 connectivity: {DATA1_HOST_V6} -> {DATA1_TARGET_V6}")
# print(f"Testing IPv6 connectivity: {DATA2_HOST_V6} -> {DATA2_TARGET_V6}")
ns1.must_reach(DATA1_TARGET_V6, timeout=5)
ns2.must_reach(DATA2_TARGET_V6, timeout=5)
firewall_ns1 = infamy.Firewall(ns1, None)
firewall_ns2 = infamy.Firewall(ns2, None)
ok, ports = firewall_ns1.verify_allowed(DATA1_TARGET_V4,
[(CUSTOM_PORT, "tcp", "myapp")])
ok, ports = firewall_ns2.verify_allowed(DATA2_TARGET_V4,
[(CUSTOM_PORT, "tcp", "myapp")])
with test.step("Verify IPv6 custom service functionality"):
ok, ports = firewall_ns1.verify_allowed(DATA1_TARGET_V6,
[(CUSTOM_PORT, "tcp", "myapp")])
ok, ports = firewall_ns2.verify_allowed(DATA2_TARGET_V6,
[(CUSTOM_PORT, "tcp", "myapp")])
with test.step("Perform dynamic zone migration"):
target.delete_xpath(f"/infix-firewall:firewall/zone[name='untrusted']/interface[.='{data1_if}']")
target.put_config_dict("infix-firewall", {
"firewall": {
"zone": [{
"name": "trusted",
"interface": [data1_if]
}]
}
})
infamy.Firewall.wait_for_operational(target, {
"untrusted": {"action": "accept"},
"trusted": {"action": "accept"}
})
with test.step("Verify connectivity after zone migration"):
ns1.must_reach(DATA1_TARGET_V4, timeout=3)
ns1.must_reach(DATA1_TARGET_V6, timeout=3)
ns2.must_reach(DATA2_TARGET_V4, timeout=3)
ns2.must_reach(DATA2_TARGET_V6, timeout=3)
with test.step("Verify custom service from migrated interface"):
firewall_migrated = infamy.Firewall(ns1, None)
ok, ports = firewall_migrated.verify_allowed(DATA1_TARGET_V4,
[(CUSTOM_PORT, "tcp", "myapp")])
assert ok, f"Custom service should work on IPv4 after zone migration"
ok, ports = firewall_migrated.verify_allowed(DATA1_TARGET_V6,
[(CUSTOM_PORT, "tcp", "myapp")])
assert ok, f"Custom service should work on IPv6 after zone migration"
with test.step("Verify operational state reflects zone changes"):
data = target.get_data("/infix-firewall:firewall")
fw = data["firewall"]
zones = {zone["name"]: zone for zone in fw["zone"]}
trusted_zone = zones["trusted"]
untrusted_zone = zones["untrusted"]
assert data1_if in trusted_zone["interface"], "data1_if should be in trusted zone"
assert data2_if in trusted_zone["interface"], "data2_if should be in trusted zone"
assert data1_if not in untrusted_zone.get("interface", []), "data1_if should no longer be in untrusted zone"
trusted_services = trusted_zone.get("service", [])
assert "myapp" in trusted_services, f"Custom service should be available in trusted zone, got: {trusted_services}"
test.succeed()
@@ -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> mgmt | <data1> data1 | <data2> data2 }",
pos="1,1!",
requires="controller"
];
target [
label="{ <mgmt> mgmt | <data1> data1 | <data2> data2 } | target",
pos="3,1!",
requires="infix",
];
host:mgmt -- target:mgmt [requires="mgmt", color="lightgray"]
host:data1 -- target:data1 [color=red, fontcolor=red, taillabel="10.1.1.0/24, fd01:1:1::/64"]
host:data2 -- target:data2 [color=green, fontcolor=green, taillabel="10.2.2.0/24, fd02:2:2::/64"]
}
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Title: 1x3 Pages: 1 -->
<svg width="440pt" height="78pt"
viewBox="0.00 0.00 440.03 78.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 74)">
<title>1x3</title>
<polygon fill="white" stroke="transparent" points="-4,4 -4,-74 436.03,-74 436.03,4 -4,4"/>
<!-- host -->
<g id="node1" class="node">
<title>host</title>
<polygon fill="none" stroke="black" points="0,-0.5 0,-69.5 108,-69.5 108,-0.5 0,-0.5"/>
<text text-anchor="middle" x="25" y="-31.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">host</text>
<polyline fill="none" stroke="black" points="50,-0.5 50,-69.5 "/>
<text text-anchor="middle" x="79" y="-54.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
<polyline fill="none" stroke="black" points="50,-46.5 108,-46.5 "/>
<text text-anchor="middle" x="79" y="-31.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">data1</text>
<polyline fill="none" stroke="black" points="50,-23.5 108,-23.5 "/>
<text text-anchor="middle" x="79" y="-8.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">data2</text>
</g>
<!-- target -->
<g id="node2" class="node">
<title>target</title>
<polygon fill="none" stroke="black" points="308.03,-0.5 308.03,-69.5 432.03,-69.5 432.03,-0.5 308.03,-0.5"/>
<text text-anchor="middle" x="337.03" y="-54.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
<polyline fill="none" stroke="black" points="308.03,-46.5 366.03,-46.5 "/>
<text text-anchor="middle" x="337.03" y="-31.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">data1</text>
<polyline fill="none" stroke="black" points="308.03,-23.5 366.03,-23.5 "/>
<text text-anchor="middle" x="337.03" y="-8.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">data2</text>
<polyline fill="none" stroke="black" points="366.03,-0.5 366.03,-69.5 "/>
<text text-anchor="middle" x="399.03" y="-31.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">target</text>
</g>
<!-- host&#45;&#45;target -->
<g id="edge1" class="edge">
<title>host:mgmt&#45;&#45;target:mgmt</title>
<path fill="none" stroke="lightgray" stroke-width="2" d="M108,-58C108,-58 308.03,-58 308.03,-58"/>
</g>
<!-- host&#45;&#45;target -->
<g id="edge2" class="edge">
<title>host:data1&#45;&#45;target:data1</title>
<path fill="none" stroke="red" stroke-width="2" d="M108,-35C108,-35 308.03,-35 308.03,-35"/>
<text text-anchor="middle" x="198.5" y="-38.8" font-family="DejaVu Serif, Book" font-size="14.00" fill="red">10.1.1.0/24, fd01:1:1::/64</text>
</g>
<!-- host&#45;&#45;target -->
<g id="edge3" class="edge">
<title>host:data2&#45;&#45;target:data2</title>
<path fill="none" stroke="green" stroke-width="2" d="M108,-12C108,-12 308.03,-12 308.03,-12"/>
<text text-anchor="middle" x="198.5" y="-15.8" font-family="DejaVu Serif, Book" font-size="14.00" fill="green">10.2.2.0/24, fd02:2:2::/64</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+1
View File
@@ -0,0 +1 @@
test.adoc
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

@@ -0,0 +1,33 @@
=== LAN-WAN Firewall with Masquerading
ifdef::topdoc[:imagesdir: {topdoc}../../test/case/infix_firewall/lan-wan]
==== Description
Typical home/office router scenario where the DUT acts as a gateway with
LAN-to-WAN traffic forwarding and masquerading (SNAT).
image::lan-wan.svg[align=center, scaledwidth=50%]
- DUT/Gateway with firewall and NAT
- Test host has two interfaces: a LAN-side and a 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
==== Topology
image::topology.svg[LAN-WAN Firewall with Masquerading topology, align=center, scaledwidth=75%]
==== Sequence
. Set up topology and attach to gateway
. Configure gateway with firewall and SNAT
. Verify LAN access to router
. Verify LAN services accessibility
. Verify WAN access to router is blocked
. Verify WAN blocks all well-known ports
. Verify LAN-to-WAN connectivity (outbound)
. Verify LAN-to-WAN masquerading
. Verify WAN-to-LAN blocking (inbound)
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""LAN-WAN Firewall with Masquerading
Typical home/office router scenario where the DUT acts as a gateway with
LAN-to-WAN traffic forwarding and masquerading (SNAT).
image::lan-wan.svg[align=center, scaledwidth=50%]
- DUT/Gateway with firewall and NAT
- Test host has two interfaces: a LAN-side and a 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
"""
import time
import infamy
from infamy.util import until
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
}
]
}
})
# Wait for configuration to be activated
infamy.Firewall.wait_for_operational(gateway, {
"lan": {"action": "accept"},
"wan": {"action": "drop"}
})
# Verify firewall operational state
data = gateway.get_data("/infix-firewall:firewall")
fw = data["firewall"]
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 LAN services accessibility"):
firewall = infamy.Firewall(lan_client, None)
svc = [
(22, "tcp", "ssh"),
(53, "udp", "dns"),
(67, "udp", "dhcp"),
]
ok, ports = firewall.verify_allowed(LAN_ROUTER_IP, svc)
if not ok:
print(f" ⚠ Some LAN services are filtered: {', '.join(ports)}")
test.fail()
with test.step("Verify WAN access to router is blocked"):
wan_server.must_not_reach(WAN_ROUTER_IP, timeout=3)
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 not ok:
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 LAN-to-WAN masquerading"):
firewall = infamy.Firewall(lan_client, wan_server)
ok, info = firewall.verify_snat(WAN_SERVER_IP, WAN_ROUTER_IP)
if not ok:
print(f"{info}")
test.fail()
with test.step("Verify WAN-to-LAN blocking (inbound)"):
wan_server.must_not_reach(LAN_CLIENT_IP, timeout=3)
test.succeed()
@@ -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> mgmt | <lan> lan | <wan> wan }",
pos="1,1!",
requires="controller"
];
gateway [
label="{ <mgmt> mgmt | <lan> lan | <wan> 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"]
}
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Title: 1x3 Pages: 1 -->
<svg width="432pt" height="78pt"
viewBox="0.00 0.00 432.03 78.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 74)">
<title>1x3</title>
<polygon fill="white" stroke="transparent" points="-4,4 -4,-74 428.03,-74 428.03,4 -4,4"/>
<!-- host -->
<g id="node1" class="node">
<title>host</title>
<polygon fill="none" stroke="black" points="0,-0.5 0,-69.5 100,-69.5 100,-0.5 0,-0.5"/>
<text text-anchor="middle" x="25" y="-31.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">host</text>
<polyline fill="none" stroke="black" points="50,-0.5 50,-69.5 "/>
<text text-anchor="middle" x="75" y="-54.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
<polyline fill="none" stroke="black" points="50,-46.5 100,-46.5 "/>
<text text-anchor="middle" x="75" y="-31.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">lan</text>
<polyline fill="none" stroke="black" points="50,-23.5 100,-23.5 "/>
<text text-anchor="middle" x="75" y="-8.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">wan</text>
</g>
<!-- gateway -->
<g id="node2" class="node">
<title>gateway</title>
<polygon fill="none" stroke="black" points="300.03,-0.5 300.03,-69.5 424.03,-69.5 424.03,-0.5 300.03,-0.5"/>
<text text-anchor="middle" x="325.03" y="-54.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
<polyline fill="none" stroke="black" points="300.03,-46.5 350.03,-46.5 "/>
<text text-anchor="middle" x="325.03" y="-31.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">lan</text>
<polyline fill="none" stroke="black" points="300.03,-23.5 350.03,-23.5 "/>
<text text-anchor="middle" x="325.03" y="-8.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">wan</text>
<polyline fill="none" stroke="black" points="350.03,-0.5 350.03,-69.5 "/>
<text text-anchor="middle" x="387.03" y="-31.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">gateway</text>
</g>
<!-- host&#45;&#45;gateway -->
<g id="edge1" class="edge">
<title>host:mgmt&#45;&#45;gateway:mgmt</title>
<path fill="none" stroke="lightgray" stroke-width="2" d="M100,-58C100,-58 300.03,-58 300.03,-58"/>
</g>
<!-- host&#45;&#45;gateway -->
<g id="edge2" class="edge">
<title>host:lan&#45;&#45;gateway:lan</title>
<path fill="none" stroke="black" stroke-width="2" d="M100,-35C100,-35 300.03,-35 300.03,-35"/>
<text text-anchor="middle" x="154.5" y="-38.8" font-family="DejaVu Serif, Book" font-size="14.00">192.168.1.0/24</text>
</g>
<!-- host&#45;&#45;gateway -->
<g id="edge3" class="edge">
<title>host:wan&#45;&#45;gateway:wan</title>
<path fill="none" stroke="red" stroke-width="2" d="M100,-12C100,-12 300.03,-12 300.03,-12"/>
<text text-anchor="middle" x="154.5" y="-15.8" font-family="DejaVu Serif, Book" font-size="14.00" fill="red">203.0.113.0/24</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+1
View File
@@ -0,0 +1 @@
test.adoc
@@ -0,0 +1,34 @@
=== WAN-DMZ-LAN Firewall with Port Forwarding
ifdef::topdoc[:imagesdir: {topdoc}../../test/case/infix_firewall/wan-dmz-lan]
==== Description
Multi-zone firewall setup with port forwarding (DNAT) to a DMZ server,
and masquerading (SNAT) of WAN-bound traffic.
image::wan-dmz-lan.svg[align=center, scaledwidth=50%]
- DUT/Gateway with WAN/DMZ/LAN zones and NAT
- Test host's WAN interface acts as external Internet client
- Test host's DMZ interface acts as internal server (HTTP on port 80)
- Test host's LAN interface acts as internal LAN client
==== Topology
image::topology.svg[WAN-DMZ-LAN Firewall with Port Forwarding topology, align=center, scaledwidth=75%]
==== 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 zone default actions/services
+284
View File
@@ -0,0 +1,284 @@
#!/usr/bin/env python3
"""WAN-DMZ-LAN Firewall with Port Forwarding
Multi-zone firewall setup with port forwarding (DNAT) to a DMZ server,
and masquerading (SNAT) of WAN-bound traffic.
image::wan-dmz-lan.svg[align=center, scaledwidth=50%]
- DUT/Gateway with WAN/DMZ/LAN zones and NAT
- Test host's WAN interface acts as external Internet client
- Test host's DMZ interface acts as internal server (HTTP on port 80)
- Test host's LAN interface acts as internal LAN client
"""
import time
import infamy
from infamy.util import until
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": "reject",
"network": [DMZ_NET],
"service": ["http"]
},
{
"name": "lan",
"description": "Internal LAN network - trusted",
"action": "accept",
"interface": [lan_if, mgmt_if]
}
],
"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"]
}
]
}
})
# Wait for configuration to be activated
infamy.Firewall.wait_for_operational(gateway, {
"wan": {"action": "drop"},
"dmz": {"action": "reject"},
"lan": {"action": "accept"}
})
# Verify firewall operational state
data = gateway.get_data("/infix-firewall:firewall")
fw = data["firewall"]
zones = {z["name"]: z for z in fw["zone"]}
# Verify WAN zone with port forwarding
wan_zone = zones["wan"]
assert wan_zone["action"] == "drop"
assert wan_if in wan_zone["interface"]
assert len(wan_zone["port-forward"]) == 1
pf = 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"] == "reject"
assert DMZ_NET in dmz_zone["network"]
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
ok, info = firewall.verify_dnat(
WAN_ROUTER_IP, forward_port=8080, target_port=80)
if not ok:
print(f"{info}")
test.fail()
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, ports = firewall.verify_allowed(DMZ_SERVER_IP, svc)
if not ok:
print(f" ⚠ Some DMZ services filtered from LAN: {', '.join(ports)}")
test.fail()
with test.step("Verify DMZ to LAN blocking"):
dmz_server.must_not_reach(LAN_CLIENT_IP, timeout=3)
with test.step("Verify WAN isolation"):
firewall = infamy.Firewall(wan_client, None)
ok, ports, _ = firewall.verify_blocked(LAN_ROUTER_IP)
if not ok:
print(f" ⚠ WAN can access LAN ports: {', '.join(ports)}")
test.fail()
ok, ports, _ = firewall.verify_blocked(DMZ_ROUTER_IP)
if not ok:
print(f" ⚠ WAN can access DMZ ports: {', '.join(ports)}")
with test.step("Verify LAN to WAN connectivity with SNAT"):
firewall = infamy.Firewall(lan_client, wan_client)
lan_client.must_reach(WAN_CLIENT_IP, timeout=3)
ok, info = firewall.verify_snat(WAN_CLIENT_IP, WAN_ROUTER_IP)
if not ok:
print(f" ⚠ LAN to WAN SNAT: {info}")
test.fail()
with test.step("Verify DMZ to WAN connectivity with SNAT"):
firewall = infamy.Firewall(dmz_server, wan_client)
dmz_server.must_reach(WAN_CLIENT_IP, timeout=3)
ok, info = firewall.verify_snat(WAN_CLIENT_IP, WAN_ROUTER_IP)
if not ok:
print(f" ⚠ DMZ to WAN SNAT: {info}")
test.fail()
with test.step("Verify zone default actions/services"):
firewall_lan = infamy.Firewall(lan_client, None)
firewall_dmz = infamy.Firewall(dmz_server, None)
firewall_wan = infamy.Firewall(wan_client, None)
svc = [
(22, "tcp", "ssh"),
(53, "udp", "dns"),
(67, "udp", "dhcp")
]
ok, ports = firewall_lan.verify_allowed(LAN_ROUTER_IP, svc)
if not ok:
print(f" ⚠ LAN services not properly accessible: {', '.join(ports)}")
svc = [(80, "tcp", "http")]
ok, ports = firewall_dmz.verify_allowed(DMZ_ROUTER_IP, svc)
if not ok:
print(f" ⚠ DMZ HTTP service not accessible: {', '.join(ports)}")
ok, ports, _ = firewall_wan.verify_blocked(WAN_ROUTER_IP)
if not ok:
print(f" ⚠ WAN has unexpected open ports: {', '.join(ports)}")
test.succeed()
@@ -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> mgmt | <wan> wan | <dmz> dmz | <lan> lan }",
pos="1,1!",
requires="controller"
];
gateway [
label="{ <mgmt> mgmt | <wan> wan | <dmz> dmz | <lan> 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"]
}
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Title: 1x4 Pages: 1 -->
<svg width="432pt" height="101pt"
viewBox="0.00 0.00 432.03 101.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 97)">
<title>1x4</title>
<polygon fill="white" stroke="transparent" points="-4,4 -4,-97 428.03,-97 428.03,4 -4,4"/>
<!-- host -->
<g id="node1" class="node">
<title>host</title>
<polygon fill="none" stroke="black" points="0,-0.5 0,-92.5 100,-92.5 100,-0.5 0,-0.5"/>
<text text-anchor="middle" x="25" y="-42.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">host</text>
<polyline fill="none" stroke="black" points="50,-0.5 50,-92.5 "/>
<text text-anchor="middle" x="75" y="-77.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
<polyline fill="none" stroke="black" points="50,-69.5 100,-69.5 "/>
<text text-anchor="middle" x="75" y="-54.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">wan</text>
<polyline fill="none" stroke="black" points="50,-46.5 100,-46.5 "/>
<text text-anchor="middle" x="75" y="-31.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">dmz</text>
<polyline fill="none" stroke="black" points="50,-23.5 100,-23.5 "/>
<text text-anchor="middle" x="75" y="-8.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">lan</text>
</g>
<!-- gateway -->
<g id="node2" class="node">
<title>gateway</title>
<polygon fill="none" stroke="black" points="300.03,-0.5 300.03,-92.5 424.03,-92.5 424.03,-0.5 300.03,-0.5"/>
<text text-anchor="middle" x="325.03" y="-77.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
<polyline fill="none" stroke="black" points="300.03,-69.5 350.03,-69.5 "/>
<text text-anchor="middle" x="325.03" y="-54.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">wan</text>
<polyline fill="none" stroke="black" points="300.03,-46.5 350.03,-46.5 "/>
<text text-anchor="middle" x="325.03" y="-31.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">dmz</text>
<polyline fill="none" stroke="black" points="300.03,-23.5 350.03,-23.5 "/>
<text text-anchor="middle" x="325.03" y="-8.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">lan</text>
<polyline fill="none" stroke="black" points="350.03,-0.5 350.03,-92.5 "/>
<text text-anchor="middle" x="387.03" y="-42.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">gateway</text>
</g>
<!-- host&#45;&#45;gateway -->
<g id="edge1" class="edge">
<title>host:mgmt&#45;&#45;gateway:mgmt</title>
<path fill="none" stroke="lightgray" stroke-width="2" d="M100,-81.5C100,-81.5 300.03,-81.5 300.03,-81.5"/>
</g>
<!-- host&#45;&#45;gateway -->
<g id="edge2" class="edge">
<title>host:wan&#45;&#45;gateway:wan</title>
<path fill="none" stroke="red" stroke-width="2" d="M100,-58.5C100,-58.5 300.03,-58.5 300.03,-58.5"/>
<text text-anchor="middle" x="154.5" y="-62.3" font-family="DejaVu Serif, Book" font-size="14.00" fill="red">203.0.113.0/24</text>
</g>
<!-- host&#45;&#45;gateway -->
<g id="edge3" class="edge">
<title>host:dmz&#45;&#45;gateway:dmz</title>
<path fill="none" stroke="orange" stroke-width="2" d="M100,-34.5C100,-34.5 300.03,-34.5 300.03,-34.5"/>
<text text-anchor="middle" x="141" y="-38.3" font-family="DejaVu Serif, Book" font-size="14.00" fill="orange">10.0.1.0/24</text>
</g>
<!-- host&#45;&#45;gateway -->
<g id="edge4" class="edge">
<title>host:lan&#45;&#45;gateway:lan</title>
<path fill="none" stroke="black" stroke-width="2" d="M100,-11.5C100,-11.5 300.03,-11.5 300.03,-11.5"/>
<text text-anchor="middle" x="154.5" y="-15.3" font-family="DejaVu Serif, Book" font-size="14.00">192.168.1.0/24</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

+2 -1
View File
@@ -1,4 +1,5 @@
USB PORTS 
──────────────────────────────
USB PORTS
NAME STATE 
USB locked
USB2 locked
+8 -6
View File
@@ -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
+2
View File
@@ -4,8 +4,10 @@ from .container import Container
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
+231
View File
@@ -0,0 +1,231 @@
"""
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
from .util import until
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
@staticmethod
def wait_for_operational(target, expected_zones, timeout=30):
"""Wait for firewall config to be activated/available in operational"""
def check_operational():
try:
oper = target.get_data("/infix-firewall:firewall")
if not oper or "firewall" not in oper:
return False
if "zone" not in oper["firewall"]:
return False
zones = {z["name"]: z for z in oper["firewall"]["zone"]}
for zone_name, expected in expected_zones.items():
if zone_name not in zones:
return False
for key, value in expected.items():
if zones[zone_name].get(key) != value:
return False
return True
except:
return False
until(check_operational, attempts=timeout)
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}"
+157
View File
@@ -0,0 +1,157 @@
"""
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}")
in6 = ""
if ":" in host:
in6 = "-6"
cmd = f"nmap -n {proto} {in6} -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
+4
View File
@@ -40,6 +40,10 @@ include::../case/ietf_routing/Readme.adoc[]
<<<
include::../case/infix_firewall/Readme.adoc[]
<<<
include::../case/infix_containers/Readme.adoc[]
<<<