On RHEL with firewalld’s nftables backend, allowing one libvirt VM network to reach another is not a classic firewall-cmd --direct problem anymore. Libvirt installs its own ip libvirt_network table, and that is what rejects NEW traffic between bridges.
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 creates:
| |
Cross-bridge packets never match guest_cross, then hit guest_input and die. firewalld runs later (filter + 10), so policies / zone forward cannot save a packet already rejected in libvirt_network.
The approach
Keep the two bridges in the libvirt zone (libvirt will put them back anyway). Insert ACCEPT rules at the head of guest_cross. That alone is enough for packets to be forwarded.
Optionally, also skip masquerade between the two guest subnets in guest_nat. That is not required for connectivity: without it, ping still works, but libvirt’s NAT rules rewrite the source to the host IP on the egress bridge (172.17.2.1 instead of 172.17.1.3). Conntrack fixes the return path, so you get a working but SNAT’d conversation. The guest_nat bypass keeps real guest source IPs.
To make that durable and editable, four files (also packaged at github.com/tinsjourney/libvirt-bridge-forward):
| File | Role |
|---|---|
/etc/libvirt/bridge-forward.conf | Which bridge may reach which bridge |
/usr/local/sbin/libvirt-bridge-forward | Apply / flush / status (talks to nft) |
/etc/systemd/system/libvirt-bridge-forward.service | Run apply at boot / on restart |
/etc/libvirt/hooks/network | Re-apply after libvirt rebuilds its nft rules |
1. Policy file — bridge-forward.conf
| |
oneway— onlyfrom → toboth— both directions
Edit this file, then restart (or reload) the systemd unit. Subnets for the NAT bypass are discovered from each bridge’s IPv4 kernel route on the host (172.17.1.0/24, 172.17.2.0/24, …).
2. Apply script — libvirt-bridge-forward
Installed as /usr/local/sbin/libvirt-bridge-forward.
Commands:
| |
On apply it roughly does:
Wait until
ip libvirt_networkchainsguest_crossandguest_natexist.Delete previous rules tagged with comment prefix
lbf-.For each conf line, insert at the head of
guest_cross:1iif <from> oif <to> acceptAnd insert in
guest_nat(optional for reachability, useful for real source IPs):1ip saddr <from_cidr> ip daddr <to_cidr> returnWithout this, libvirt still masquerades
172.17.1.0/24 → !172.17.1.0/24, so traffic to the other guest net is SNATed via the host.If direction is
both, do the reverse pair as well.
Managed rules are identified by comments such as lbf-c-br-network-1-to-br-network-2, so re-apply is idempotent.
3. Systemd unit — libvirt-bridge-forward.service
| |
Enable and use:
| |
RemainAfterExit=yes makes restart/reload meaningful for a oneshot service.
4. Libvirt network hook — /etc/libvirt/hooks/network
Whenever libvirt starts or restarts a virtual network, it rebuilds ip libvirt_network and your inserts disappear. The hook puts them back:
| |
Make it executable (chmod +x) and give it the right SELinux type:
| |
On RHEL with SELinux Enforcing, virtnetworkd still cannot run the hook until this boolean is on (otherwise virsh net-start fails with exit 126 / Permission denied, and autostart networks never come up after reboot):
| |
The hook runs the same apply path as the systemd unit, so the conf stays the single source of truth.
How a packet is allowed
Example: VM on br-network-1 (172.17.1.3) pings VM on br-network-2 (172.17.2.5).
- Guest routes via
172.17.1.1(host on the bridge). - Host forwards (
net.ipv4.ip_forward=1). - Packet hits
libvirt_networkforward hook:guest_cross: matchiif br-network-1 oif br-network-2→ accept (our rule).
guest_nat: matchsaddr 172.17.1.0/24 daddr 172.17.2.0/24→ return (skip masquerade; omit this and the packet is still forwarded, but SNATed).- firewalld forward path for the
libvirtzone already accepts intra-zone forward.
Without step 3, guest_input would REJECT the NEW flow toward br-network-2. Step 4 only preserves the real source address.
Also keep firewalld zone forward enabled:
| |
Quick real example — one nft rule (one way)
To allow only br-network-1 → br-network-2 by hand (no conf, no service), insert this at the head of libvirt’s guest_cross chain:
| |
That single rule is enough for one-way forwarding. It is wiped when the libvirt network is restarted unless you re-add it (or use the four files above).
If you also want the destination VM to see the real source IP (no SNAT via the host), add:
| |