Skip to content

Firewall changes are silently ignored on large configurations #1869

Description

@gsanchietti

Component and version

NethSecurity 8.8.0 — firewall4 (firewall4-2025.03.17~b6e51575-r2)

Actual behavior

On firewalls with many port forwards and many zones, applying the firewall configuration fails halfway through building the rules. The new rules are then never loaded: rules are applied all-or-nothing, so the incomplete set is discarded and the firewall keeps enforcing its previous rules. The command still reports success.

The practical consequence: an administrator can close a port, remove an access rule, or add a port forward, see no error at all, and reasonably believe the change is active — when it is not. The firewall keeps applying the old rules until someone notices.

fw4 reload prints:

Runtime error: Unable to open source file /usr/share/firewall4/templates/redirect.uc: No file descriptors available
In main(), file /usr/share/firewall4/templates/ruleset.uc, line 338, byte 53:
  called from function include ([C])
  called from function render_ruleset (/usr/share/firewall4/main.uc:58:72)

/dev/stdin:1698:3-3: Error: syntax error, unexpected end of file

Note that fw4 reload still exits with status 0, so scripts and automation cannot detect the failure.

Seen on a production firewall with 315 configuration sections (66 port forwards, 23 zones).

Expected behavior

Either the configuration is applied successfully, or the command fails visibly with a non-zero exit status.

Cause

While building the rules, one file is opened for each rule template used, and none are closed until the whole set is finished. Every port forward also multiplies this, because each "reflection" zone listed on a port forward is rendered separately.

Large configurations therefore exceed the limit of 1024 open files per process. Measured on the test configuration below: 1060 files needed against 1024 allowed — only ~36 over. This means affected systems sit just barely over the threshold, and adding a single port forward can be enough to trigger it. It also makes the problem easy to miss in testing: one fewer port forward, or a WAN interface without an address, keeps it just under the limit and everything appears fine.

Steps to reproduce

Requires a test machine with a spare, unused network card, and a WAN interface that has an IP address.

  1. Save the script below as /tmp/gen.sh and run it (it backs up the current configuration to /root/firewall.bak first):

    sh /tmp/gen.sh
  2. Apply the configuration:

    fw4 reload
  3. The error above is printed. Confirm the new rules were not applied — the count is unchanged from before the reload:

    nft list table inet fw4 | grep -c "dnat ip to"
  4. Restore:

    cp /root/firewall.bak /etc/config/firewall && fw4 reload
Script to generate the test configuration (320 sections)
#!/bin/sh
# Generates a large firewall configuration that reproduces the
# "No file descriptors available" failure of `fw4 reload`.
#
# Run on a TEST machine only. Backs up the current config first.
# Restore with:  cp /root/firewall.bak /etc/config/firewall && fw4 reload

set -e

SPARE_NIC="${SPARE_NIC:-eth2}"   # any spare, unused network card

cp /etc/config/firewall /root/firewall.bak
echo "Current configuration saved to /root/firewall.bak"

# A second local network. The extra zones below are attached to it: zones
# without a real network card produce far fewer rules and will NOT trigger
# the problem.
uci set network.lan2=interface
uci set network.lan2.device="$SPARE_NIC"
uci set network.lan2.proto='static'
uci set network.lan2.ipaddr='10.10.30.2'
uci set network.lan2.netmask='255.255.255.0'
uci commit network
ifup lan2

ZONES="dmz guest ipsec openvpn rwopenvpn vlan101 vlan102 vlan103 vlan104 vlan105
vlan106 vlan107 vlan108 vlan109 vlan110 vlan111 vlan112 vlan113 vlan114 vlan115 vlan116"

# 21 extra zones
for z in $ZONES; do
	uci add firewall zone >/dev/null
	uci set firewall.@zone[-1].name="$z"
	uci add_list firewall.@zone[-1].network='lan2'
	uci set firewall.@zone[-1].input='ACCEPT'
	uci set firewall.@zone[-1].output='ACCEPT'
	uci set firewall.@zone[-1].forward='ACCEPT'
done

ALLZONES="lan wan $ZONES"

# 66 port forwards, each reflected into 3 zones (this is the main multiplier)
i=0
for port in $(seq 20001 20066); do
	i=$((i + 1))
	uci add firewall redirect >/dev/null
	uci set firewall.@redirect[-1].name="ns_pf${i}"
	uci set firewall.@redirect[-1].src='wan'
	uci set firewall.@redirect[-1].dest='lan'
	uci set firewall.@redirect[-1].target='DNAT'
	uci set firewall.@redirect[-1].proto='tcp'
	uci set firewall.@redirect[-1].src_dport="$port"
	uci set firewall.@redirect[-1].dest_ip='192.168.100.50'
	uci set firewall.@redirect[-1].dest_port="$port"
	uci set firewall.@redirect[-1].reflection='1'
	for off in 0 7 13; do
		z=$(echo $ALLZONES | tr ' ' '\n' | sed -n "$(((i + off) % 23 + 1))p")
		uci add_list firewall.@redirect[-1].reflection_zone="$z"
	done
done

# 185 zone-to-zone traffic rules
count=0
for s in $ALLZONES; do
	for d in $ALLZONES; do
		[ "$s" = "$d" ] && continue
		count=$((count + 1))
		[ $count -gt 185 ] && break 2
		uci add firewall forwarding >/dev/null
		uci set firewall.@forwarding[-1].src="$s"
		uci set firewall.@forwarding[-1].dest="$d"
	done
done

# 18 traffic rules
for i in $(seq 1 18); do
	uci add firewall rule >/dev/null
	uci set firewall.@rule[-1].name="ns_rule_${i}"
	uci set firewall.@rule[-1].src='wan'
	uci set firewall.@rule[-1].proto='tcp'
	uci set firewall.@rule[-1].dest_port="$((40000 + i))"
	uci set firewall.@rule[-1].target='ACCEPT'
done

# 12 IP sets
for i in $(seq 1 12); do
	uci add firewall ipset >/dev/null
	uci set firewall.@ipset[-1].name="ns_ipset_${i}"
	uci add_list firewall.@ipset[-1].match='src_net'
done

# 8 NAT rules
for i in $(seq 1 8); do
	uci add firewall nat >/dev/null
	uci set firewall.@nat[-1].name="ns_nat_${i}"
	uci set firewall.@nat[-1].target='SNAT'
	uci set firewall.@nat[-1].snat_ip="192.168.100.$((200 + i))"
	uci set firewall.@nat[-1].src='lan'
done

uci commit firewall

echo "Generated $(grep -c '^config' /etc/config/firewall) configuration sections."
echo "Now run:  fw4 reload"

Workaround

Allow the process to open more files. Run on the affected firewall:

cp /sbin/fw4 /root/fw4.orig-backup
grep -q 'ulimit -HSn 8192' /sbin/fw4 || sed -i '/^set -o pipefail/a ulimit -HSn 8192 2>/dev/null' /sbin/fw4

Then fw4 reload completes and the configuration is applied correctly.

Tested: it is safe to run more than once, and it still works when the caller passes down a lower limit.

This is only a temporary workaround/sbin/fw4 ships with the firmware, so the change is lost on the next update.

Suggested fix

Ship the same ulimit line in the firewall4 package so it survives updates.

The underlying defect is that template files are held open until the whole set of rules has been built; they could be released as soon as each template has been read. That part is upstream in ucode. Raising the limit is a much smaller change and is enough to resolve this in practice, though it raises a ceiling rather than removing the cause (8192 covers roughly 2500 configuration sections).

Resources

Related upstream issue: openwrt/openwrt#24598

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    Status
    ToDo 🕐

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions