No DHCP for an LXC container while iptables counts the packets as ACCEPT: firewalld rejects them in its own nftables table

·5 mins

A new LXC container came up with an IPv6 address and no IPv4 address. According to iptables, the host allowed DHCP from the container bridge, and the counter on that rule kept growing:

$ sudo iptables -L INPUT -v -n | grep dpt:67
    0     0 ACCEPT     tcp  --  lxcbr0 *       0.0.0.0/0            0.0.0.0/0            tcp dpt:67
   26  7956 ACCEPT     udp  --  lxcbr0 *       0.0.0.0/0            0.0.0.0/0            udp dpt:67

Twenty-six packets accepted, and dnsmasq, listening on that bridge, never logged a single DHCPDISCOVER.

With nftables, accept only ends the chain it sits in. Any other base chain on the same hook still gets the packet. On this host, the next one belonged to firewalld, and firewalld rejected everything arriving on an interface that wasn’t in any of its zones.

The setup

  • Debian 13 host with the stock lxc-net bridge: lxcbr0, 10.0.3.0/24, dnsmasq at 10.0.3.1
  • firewalld 2.3.1 with FirewallBackend=nftables, default zone public
  • iptables 1.8.11, the nf_tables flavor Debian installs as iptables
  • an unprivileged Debian 13 container running systemd-networkd with DHCP=true

When lxc-net brings up the bridge, it adds its own iptables rules: ACCEPT for DNS and DHCP on lxcbr0, ACCEPT for forwarding, and a MASQUERADE for the subnet. It knows nothing about firewalld.

Following the packet

The first visible symptom was the container’s bootstrap script, which spent nine minutes waiting on DNS lookups that timed out. From there, each check moved the problem one step along the path:

  1. The container sent requests. With debug logging, systemd-networkd showed DHCPv4 client: DISCOVER in the selecting state.
  2. The host received them. The ACCEPT counter went from 20 to 26 to 29 while I made the client retry with networkctl reconfigure eth0.
  3. dnsmasq had the right socket open: 0.0.0.0%lxcbr0:67.
  4. dnsmasq received nothing. strace showed it sitting in poll(). During a retry, it read only an IPv6 router solicitation, which it answered. That is where the IPv6 address came from.

AppArmor (no denials), reverse-path filtering (loose on the bridge), br_netfilter (not loaded), leftover iptables-legacy tables (empty), and socket buffer drops (RcvbufErrors 0) all came up clean. The host had no tcpdump, and no nft either:

$ sudo nft list ruleset
sudo: nft: command not found

That left iptables as the only view of the firewall, and iptables shows only the tables it created. So the packet passed an ACCEPT at the input hook and never reached the socket. Listing the running services found a second firewall:

firewalld.service   loaded active running   firewalld - dynamic firewall daemon
$ sudo firewall-cmd --get-zone-of-interface=lxcbr0
no zone

Two tables on one hook

After apt install nftables (the binary is enough; leave nftables.service disabled), you can see both chains on the input hook. Trimmed:

$ sudo nft list chains
table ip filter {
	chain INPUT {
		type filter hook input priority filter; policy accept;
	}
}
table inet firewalld {
	chain filter_INPUT {
		type filter hook input priority filter + 10; policy accept;
	}
}

ip filter holds lxc-net’s rules, written through iptables-nft. inet firewalld runs right after it, and an interface that no zone claims falls through to the default zone:

chain filter_INPUT_POLICIES {
	...
	jump filter_IN_policy_allow-host-ipv6
	jump filter_IN_public
	reject with icmpx admin-prohibited
}

public allowed SSH and the DHCPv6 client, but no DHCP server and no DNS, so the DISCOVER hit the final reject. The zone chain also ends with meta l4proto { icmp, ipv6-icmp } accept, which is why the router solicitation got through.

The fix

Put the bridge in a firewalld zone. trusted accepts everything, including forwarded traffic, which the containers need for internet access:

sudo firewall-cmd --zone=trusted --add-interface=lxcbr0
sudo firewall-cmd --permanent --zone=trusted --add-interface=lxcbr0

Or with Ansible:

- name: Trust the container bridge in firewalld
  ansible.posix.firewalld:
    zone: trusted
    interface: lxcbr0
    permanent: true
    immediate: true
    state: enabled

The container got its lease on the client’s next retry, without restarting anything. trusted also lets containers reach every port on the host. If that’s too much, give the bridge a zone of its own that allows only DHCP, DNS, and forwarding. The same applies to any interface that another tool creates, such as a VPN tunnel.

Why the counter lied

The nftables wiki says that “an accept verdict … isn’t necessarily final”, while “drops take immediate effect, with no further rules or chains being evaluated.” Base chains on one hook run in priority order. An accept ends one chain, and a drop or reject in any chain wins. When two firewalls share a host, the stricter one decides, and the permissive one keeps counting packets that never arrive.

Most of the time you won’t see the second table. iptables -L lists only what iptables-nft created, and a host running firewalld may not have the nft command at all. (That missing binary also leaves fail2ban unable to ban anyone .)

Takeaway

A counter on an ACCEPT rule only proves that the packet reached that rule. When an accepted packet still doesn’t arrive, list every base chain on the hook and find the later one that drops it.

If your host runs firewalld alongside anything that writes its own iptables rules, run sudo nft list chains | grep hook and count the chains on input and forward.