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/24
  • br-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

HostLibvirt firewallWhere cross-bridge rules live
RHEL 10+Native nftablesip libvirt_network → guest_cross / guest_nat
RHEL 9iptables backend (iptables-nft)ip filter → LIBVIRT_FWX, ip nat → LIBVIRT_PRT

Detect on the host:

1
2
nft list tables | grep libvirt_network && echo "RHEL 10+ native nftables"
iptables -L LIBVIRT_FWX -n &>/dev/null && echo "RHEL 9 iptables-nft"

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

ScriptInstallTarget
libvirt-bridge-forward./install.shRHEL 10+
libvirt-bridge-forward-iptables./install-iptables.shRHEL 9

Shared policy file — bridge-forward.conf

1
2
# <from_bridge>  <to_bridge>  <oneway|both>
br-network-1  br-network-2  both
  • oneway — only from → to
  • both — 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:

1
2
3
4
table ip libvirt_network
  chain guest_cross   # only accepts iif==oif (same bridge)
  chain guest_input   # NEW inbound to a bridge is REJECT
  chain guest_nat     # masquerade guest → elsewhere

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).

FileRole
/etc/libvirt/bridge-forward.confPolicy
/usr/local/sbin/libvirt-bridge-forwardapply / flush / status
/etc/systemd/system/libvirt-bridge-forward.serviceBoot / restart
/etc/libvirt/hooks/networkRe-apply after libvirt rebuilds nft rules
1
2
sudo ./install.sh
systemctl restart libvirt-bridge-forward.service

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

  1. Guest routes via the host bridge IP (172.17.1.1).
  2. Host forwards (net.ipv4.ip_forward=1).
  3. guest_cross: iif br-network-1 oif br-network-2 → accept.
  4. guest_nat: saddr 172.17.1.0/24 daddr 172.17.2.0/24 → return (optional; skip SNAT).
  5. firewalld libvirt zone forward accepts intra-zone traffic.

RHEL 9 — iptables-nft (LIBVIRT_*)

Libvirt’s iptables backend creates chains that show up through iptables-nft, for example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
table ip filter
  chain FORWARD
    jump LIBVIRT_FWX
    jump LIBVIRT_FWI
    jump LIBVIRT_FWO
  chain LIBVIRT_FWX
    iifname "virbr0" oifname "virbr0" accept   # same bridge only

table ip nat
  chain POSTROUTING
    jump LIBVIRT_PRT
  chain LIBVIRT_PRT
    ... masquerade for each guest subnet ...

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).

FileRole
/etc/libvirt/bridge-forward.confSame policy format
/usr/local/sbin/libvirt-bridge-forward-iptablesapply / flush / status
/etc/systemd/system/libvirt-bridge-forward-iptables.serviceBoot / restart
/etc/libvirt/hooks/network (from hooks/network-iptables)Re-apply after network start
1
2
sudo ./install-iptables.sh
systemctl restart libvirt-bridge-forward-iptables.service

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.

1
2
3
chmod +x /etc/libvirt/hooks/network
restorecon -vF /etc/libvirt/hooks/network
setsebool -P virt_hooks_unconfined on

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:

1
2
firewall-cmd --permanent --zone=libvirt --add-forward
firewall-cmd --reload

Quick real examples — one-way forward by hand

RHEL 10+ (native nftables):

1
2
nft insert rule ip libvirt_network guest_cross \
  iif "br-network-1" oif "br-network-2" counter accept

Optional real source IPs:

1
2
nft insert rule ip libvirt_network guest_nat \
  ip saddr 172.17.1.0/24 ip daddr 172.17.2.0/24 counter return

RHEL 9 (iptables-nft):

1
iptables -I LIBVIRT_FWX 1 -i br-network-1 -o br-network-2 -j ACCEPT

Optional real source IPs:

1
iptables -t nat -I LIBVIRT_PRT 1 -s 172.17.1.0/24 -d 172.17.2.0/24 -j RETURN

These one-liners vanish when the libvirt network is restarted unless you use the packaged scripts and hook.