Allowing one libvirt VM network to reach another is not a classic firewall-cmd --direct problem anymore. Libvirt’s own NAT firewall rejects NEW traffic between bridges. The layout differs by RHEL version — and so do the tools.
The problem
Two isolated libvirt NAT networks, for example:
br-network-1→172.17.1.0/24br-network-2→172.17.2.0/24
Each VM can reach the host and the outside world (via NAT). Ping from one network to the other fails with Destination Port Unreachable from the host bridge IP.
firewalld may already show forward: yes on the libvirt zone. That is not enough: libvirt rejects the packet first.
Two backends, two toolchains
| Host | Libvirt firewall | Where cross-bridge rules live |
|---|---|---|
| RHEL 10+ | Native nftables | ip libvirt_network → guest_cross / guest_nat |
| RHEL 9 | iptables backend (iptables-nft) | ip filter → LIBVIRT_FWX, ip nat → LIBVIRT_PRT |
Detect on the host:
| |
On RHEL 9 there is typically no ip libvirt / ip libvirt_network table. Chains appear in the standard filter / nat tables managed by iptables-nft.
Packaged scripts (same conf file): github.com/tinsjourney/libvirt-bridge-forward
| Script | Install | Target |
|---|---|---|
libvirt-bridge-forward | ./install.sh | RHEL 10+ |
libvirt-bridge-forward-iptables | ./install-iptables.sh | RHEL 9 |
Shared policy file — bridge-forward.conf
| |
oneway— onlyfrom → toboth— both directions
Edit, then restart the matching systemd unit. Subnets for the NAT bypass come from each bridge’s IPv4 kernel route on the host.
RHEL 10+ — native nftables
Libvirt creates:
| |
Cross-bridge packets miss guest_cross, hit guest_input, and die. firewalld runs later (filter + 10), so zone forward cannot help.
Approach
Keep bridges in the libvirt zone. Insert ACCEPT at the head of guest_cross (required for forward). Optionally insert return in guest_nat so guest↔guest is not masqueraded (otherwise the peer sees the host IP on the egress bridge; conntrack still makes ping work).
| File | Role |
|---|---|
/etc/libvirt/bridge-forward.conf | Policy |
/usr/local/sbin/libvirt-bridge-forward | apply / flush / status |
/etc/systemd/system/libvirt-bridge-forward.service | Boot / restart |
/etc/libvirt/hooks/network | Re-apply after libvirt rebuilds nft rules |
| |
On apply, the script waits for guest_cross / guest_nat, removes previous lbf-* rules, then inserts forward + NAT bypass pairs from the conf.
Packet path
- Guest routes via the host bridge IP (
172.17.1.1). - Host forwards (
net.ipv4.ip_forward=1). guest_cross:iif br-network-1 oif br-network-2→ accept.guest_nat:saddr 172.17.1.0/24 daddr 172.17.2.0/24→ return (optional; skip SNAT).- firewalld
libvirtzone forward accepts intra-zone traffic.
RHEL 9 — iptables-nft (LIBVIRT_*)
Libvirt’s iptables backend creates chains that show up through iptables-nft, for example:
| |
LIBVIRT_FWX is the equivalent of guest_cross. Prefer iptables to insert rules (those tables are managed by iptables-nft; raw nft on ip nat warns do not touch).
| File | Role |
|---|---|
/etc/libvirt/bridge-forward.conf | Same policy format |
/usr/local/sbin/libvirt-bridge-forward-iptables | apply / flush / status |
/etc/systemd/system/libvirt-bridge-forward-iptables.service | Boot / restart |
/etc/libvirt/hooks/network (from hooks/network-iptables) | Re-apply after network start |
| |
On apply, rules are inserted at the head of LIBVIRT_FWX and LIBVIRT_PRT with comment prefix lbf-.
Libvirt network hook + SELinux
When a virtual network starts, libvirt rebuilds its firewall rules and custom inserts disappear. The hook calls apply again.
| |
Without virt_hooks_unconfined, virtnetworkd fails the hook with exit 126 / Permission denied, and autostart networks never come up after reboot.
Also enable firewalld zone forward:
| |
Quick real examples — one-way forward by hand
RHEL 10+ (native nftables):
| |
Optional real source IPs:
| |
RHEL 9 (iptables-nft):
| |
Optional real source IPs:
| |
These one-liners vanish when the libvirt network is restarted unless you use the packaged scripts and hook.