A reimplemented firewall for Proxmox VE, designed to fix PVE's pain points.
Does not modify PVE source code or configuration files.
Simply reads .fw files and generates corresponding rules.
Solves the following pain points:
- Does not use the
tap → 🧱fwbr🧱→ fwpr ↔ fwln → vmbr0architecture- Native iptables has limited capabilities, forcing the introduction of
fwbrfor firewalling - This causes packets to traverse 5 virtual NICs — a serious waste
- Native iptables has limited capabilities, forcing the introduction of
- For Linux bridges, uses nftables bridge table
- Direct path:
tap🧱 → vmbr0
- Direct path:
- For OVS bridges, uses native OpenFlow rules
- Avoids
fwbrand the overhead of routing back through the Linux kernel for nftables
- Avoids
- All rules are per-port, unlike PVE's native firewall which can only enable/disable per-VM
- macspoof: only allow specific src MAC, prevent MAC spoofing
- ipspoof: only allow specific src IP, prevent IP spoofing
- nodhcp: block DHCP server
- nora: block sending RA
- pvefw-neo ignores every VM-level toggle PVE exposes in
VM → Firewall → Options(policy_in,policy_out,dhcp,macfilter,ipfilter,ndp,radv). Only theFirewallenable switch on that tab is honored. Equivalent behaviour is per-port via@neo:tags — which is what makes (4) possible in the first place. Want default-drop on a specific NIC? Add an explicitIN DROP -i netNrule at the end of that NIC's chain.
Current limitations:
- REJECT rules are ineffective — replaced with DROP
- nftables cannot do REJECT in the
bridgefamily, onlyinetcan - But using
inetwould require going back to thefwbrarchitecture, so we give up on REJECT
- nftables cannot do REJECT in the
- PVE must enable nftables (tech preview)
- Even if node firewall / VM firewall are disabled, this must be enabled
- Because iptables mode causes
pve-firewallto generate rules (even when node/VM firewall is disabled) - Therefore
pvefw-neois incompatible with pve-firewall's iptables mode — must switch to nftables mode
- VM NICs must have firewall disabled
pvefw-neo's design: NICs with firewall checked are managed byproxmox-firewall- Regardless of cluster/node/VM firewall settings, if NIC firewall is checked, OVS will create
fwbr, preventing OpenFlow rules from being applied - To avoid conflicts, only NICs without the checkbox are controlled by
pvefw-neopvefw-neo's own port-level enable control is in@neo:disable
How it works:
- Read existing PVE
/etc/pve/firewall/*.fwconfiguration files - Preprocessing
- Compile to IR
- Query whether port belongs to Linux bridge or OVS, call respective backend
- Compile to nftables / OpenFlow rules
Regardless of whether PVE's native firewall is enabled, nftables mode (tech preview) must be turned on on the host:
PVE WebUI → Host → Firewall → Options → nftables (tech preview) → yes
curl -sSL https://raw.githubusercontent.com/PoemaIX/pvefw-neo/refs/heads/main/install.sh | bashThe script will ask whether to migrate configuration files.
Enable the firewall:
systemctl enable --now pvefw-neoUpgrade / uninstall:
bash /usr/local/lib/pvefw_neo/upgrade.sh
bash /usr/local/lib/pvefw_neo/uninstall.shUse PVE WebUI (VM → Firewall) and edit firewall rules.
The daemon detects changes and re-applies within a few seconds.
- Existing PVE rules work as-is
- pvefw-neo provides two classes of extensions, both placed in the comment field
of PVE rules, prefixed with
@neo:
Interface field empty = applies to every NIC. A rule without a value in the
Interface column (i.e. no -iface netN) is fanned out to every vNIC the
VM has. This is what makes a single rule stand in for the VM-level toggles that
pvefw-neo deliberately ignores:
| Old (PVE VM [OPTIONS]) | New (a single rule with no Interface) |
|---|---|
policy_in: DROP |
IN DROP |
policy_out: DROP |
OUT DROP |
policy_in: REJECT |
IN DROP (bridge family can't REJECT; see Limitations) |
Want the catch-all on just one NIC? Add an Interface value and it stops
fanning out: IN DROP -iface net1.
Rules evaluate top-to-bottom. PVE's WebUI Add inserts each new rule
at the top (position 0), so the visible order from top to bottom is
reverse-creation-order. Put catch-alls in first:
- Create the catch-all (
IN DROP, no interface). - Then create each specific
IN ACCEPT ...rule.
Resulting list:
pos 0 IN ACCEPT -p tcp -dport 22 ← last added, evaluates first
pos 1 IN ACCEPT -p tcp -dport 443
pos 2 IN ACCEPT -p icmp
pos 3 IN DROP ← first added, evaluates last (catch-all)
Reverse the order and the catch-all drops everything before any ACCEPT gets the chance to match.
These rules provide functionality not available in PVE's native firewall.
Since PVE WebUI has no corresponding fields, we cannot edit them directly.
We borrow the long-obsolete Finger protocol (TCP/79) as a carrier and write the real rules in the comment field. Because the per-NIC firewall checkbox is off, PVE never actually renders the Finger rule into iptables/nftables — so leaving the rule Enabled is safe and preferred: the WebUI shows it as active, and if pvefw-neo has to auto-disable it due to an apply failure, the checkbox flipping off is an immediate visual cue.
WebUI steps (VM → Firewall → Add)
First fill in the following fields (shared skeleton for all extension rules):
| Field | Value |
|---|---|
| Direction | out |
| Enable | checked |
| Action | DROP |
| Macro | Finger |
| Source / Comment | filled per tag (see below) |
Unchecking a Finger+
@neo:rule is how you turn that extension off. pvefw-neo also sets the checkbox to unchecked automatically when nftables/OVS rejects the compiled rule (see "Quarantine" behavior).
| Tag | Effect |
|---|---|
@neo:disable |
Turn off pvefw-neo management for this port. All other rules on this port are ignored, traffic passes through freely. Equivalent to PVE's "port-level firewall disable" (we can't use PVE's GUI flag because checking it causes PVE to create fwbr, which is incompatible). |
@neo:isolated |
Set kernel bridge isolated on (Linux) or corresponding OF rules (OVS).Two isolated ports on the same bridge cannot communicate; isolated ↔ non-isolated can. |
All other Extension rules are syntactic sugar (see "Syntactic Sugar" section below).
Decorators attach to real (non-Finger) PVE rules. Some change rule behavior, others narrow the match scope.
Use real macros / actions, and check Enable in the WebUI.
| Tag | Effect |
|---|---|
@neo:noct |
Evaluate before conntrack, per-packet matching. Alias: @neo:stateless |
@neo:ct |
Stateful rule, matches all packets that reach the ct_state check.Alias: not writing anything. If neither @neo:noct nor @neo:ct is written, this is the default. |
@neo:ct new |
Stateful rule, matches only ct_state=new packets. |
@neo:ct invalid |
Stateful rule, matches only ct_state=invalid packets. |
| Tag | Effect |
|---|---|
@neo:srcmac in <mac1,mac2> |
This rule only applies to packets whose source MAC matches <mac>. |
@neo:srcmac notin <mac1,mac2> |
This rule only applies to packets whose source MAC does NOT match <mac>. |
@neo:srcmac bitmask <mask> |
Match source MAC by bitmask (field & mac == mac). |
@neo:dstmac in <mac1,mac2> |
This rule only applies to packets whose destination MAC matches <mac>. |
@neo:dstmac notin <mac1,mac2> |
This rule only applies to packets whose destination MAC does NOT match <mac>. |
@neo:dstmac bitmask <mask> |
Match destination MAC by bitmask (field & mac == mac). |
@neo:vlan <untagged|vid1,vid2> |
Only apply to traffic on the specified VLAN(s). Used when a trunk port is given to a VM and rules should only apply to a specific inner VLAN. |
@neo:rateexceed <pps> |
Only match the portion of traffic exceeding <pps>.Packets within the rate budget don't match and fall through to the next rule. Constraints: @neo:stateless only (not on @neo:ct), and action must be DROP/REJECT. ACCEPT + rateexceed has no sensible meaning and OVS meters can't express an "accept" band — compiler rejects with a warning and drops the rule. |
A rule's source, dest and @neo:ether tag must agree on IP family or the
compiler skips the rule with a stderr warning (no backend quarantine —
the rule simply doesn't make it to nft/OVS).
Step 1 — src × dst alignment (strict). When both source and dest
are non-null, their families must match exactly: v4 == v4, v6 == v6,
mixed == mixed (an ipset containing both families counts as mixed).
Any other combination is rejected (e.g. source 10.0.0.5 / dest 2001:db8::1).
The agreed family becomes l3_afs; if one side is null the other decides.
Step 2 — l3_afs × @neo:ether intersection (partial). @neo:ether ip
and @neo:ether arp both mean v4 (ARP's spa/tpa fields are v4); @neo:ether ip6
means v6. When the rule is mixed l3_afs and @neo:ether is explicit,
the compiler emits only the matching-family variant — the other family
is silently skipped for this rule, but the ipset definition stays intact so
other rules referencing the same set still get their full set.
| l3_afs ↓ / @neo:ether → | (none) | ip |
ip6 |
arp |
|---|---|---|---|---|
| (none) | OK | OK | OK | OK |
| v4 | OK | OK | REJECT | OK |
| v6 | OK | REJECT | OK | REJECT |
| mixed | OK (emit v4 + v6) | OK (emit v4 only) | OK (emit v6 only) | OK (emit v4 only) |
Examples:
Src MAC whitelist:
| Direction | Action | Macro | Source | Comment |
|---|---|---|---|---|
out |
DROP |
(none) | (none) | @neo:stateless @neo:srcmac notin aa:bb:cc:dd:ee:ff |
Stateless Src IP whitelist:
| IPSet | IPs |
|---|---|
nonself |
!192.168.66.1/32 |
| Direction | Action | Macro | Source | Comment |
|---|---|---|---|---|
out |
DROP |
(none) | +guest/nonself |
@neo:stateless |
VLAN-scoped stateless rule (trunk port, only apply to inner VLAN 20):
| Direction | Action | Macro | Source | Comment |
|---|---|---|---|---|
out |
ACCEPT |
(none) | 10.0.0.0/24 |
@neo:stateless @neo:vlan 20 |
Drop ct invalid (per-port, stateful):
| Direction | Action | Macro | Source | Comment |
|---|---|---|---|---|
in |
DROP |
(none) | (none) | @neo:ct invalid |
out |
DROP |
(none) | (none) | @neo:ct invalid |
Allow outbound only, block inbound (reject inbound ct_state=new):
| Direction | Action | Macro | Source | Comment |
|---|---|---|---|---|
in |
DROP |
(none) | (none) | @neo:ct new |
The following Extension rules are syntactic sugar — they are expanded into decorator rule combinations at compile time, and the sugar itself disappears.
Usage is the same as Extension rules (Finger skeleton + comment).
Only allow source MACs in the list; drop the rest. No args = auto-read from VM config.
@neo:macspoof
Expands to:
| Direction | Action | Comment |
|---|---|---|
out |
DROP |
@neo:noct @neo:srcmac notin <mac> (MAC auto-read from NIC config) |
Only allow listed source IPs. Automatically handles ARP / IPv4 / IPv6.
Put the IP list in the Source field or the comment args (choose one).
(Note: PVE Source field does not accept mixed v4/v6.
For mixed v4 + v6, use the comment syntax: @neo:ipspoof 10.0.0.5,2001:db8::1
or split into two rules.)
Using comment syntax as an example:
@neo:ipspoof 192.168.16.3,192.168.30.0/24,2001:db8::1,2a0a:6040::/32
Expands to 2 pure-nomatch ipsets + 3 stateless rules (ARP / v4 / v6):
[IPSET ipspoof_vm100_net0_v4] ← v4 allow list (inverted)
!192.168.16.3
!192.168.30.0/24
[IPSET ipspoof_vm100_net0_v6] ← v6 auto-includes link-local + DAD
!fe80::/64
!2001:db8::1
!2a0a:6040::/32
!::
| Direction | Action | Source | Comment |
|---|---|---|---|
out |
DROP |
+ipspoof_vm100_net0_v4 |
@neo:noct @ether arp op {request,reply} (ARP protection) |
out |
DROP |
+ipspoof_vm100_net0_v4 |
@neo:noct (IPv4 protection) |
out |
DROP |
+ipspoof_vm100_net0_v6 |
@neo:noct (IPv6 protection) |
The IPv6 ipset always auto-includes
fe80::/64(link-local) and::(DAD), ensuring the VM can perform Neighbor Discovery. If the user specifies additional v6 addresses (e.g.2001:db8::1), they are also added to this ipset.
Block the VM from acting as a DHCP server (drop UDP sport 67/547 → dport 68/546).
@neo:nodhcp
Expands to (one v4 + one v6 rule):
| Direction | Proto | Src Port | Dst Port | Action | Comment |
|---|---|---|---|---|---|
out |
udp |
67 |
68 |
DROP |
@neo:noct @ether ip (v4) |
out |
udp |
547 |
546 |
DROP |
@neo:noct @ether ipv6 (v6) |
Block outbound IPv6 Router Advertisement.
@neo:nora
Expands to:
| Direction | Proto | ICMP Type | Action | Comment |
|---|---|---|---|---|
out |
DROP |
ipv6-icmp |
router-advertisement |
@neo:noct |
@neo:nondp
Expands to:
| Direction | Proto | ICMP Type | Action | Comment |
|---|---|---|---|---|
out |
DROP |
ipv6-icmp |
neighbour-solicitation |
@neo:noct |
out |
DROP |
ipv6-icmp |
neighbour-advertisement |
@neo:noct |
Rate-limit multicast frames sent by the VM.
@neo:mcast_limit 100
Expands to:
| Direction | Action | Comment |
|---|---|---|
out |
DROP |
@neo:noct @neo:rateexceed 100 @neo:dstmac bitmask 01:00:00:00:00:00 |
Drop ct_state=invalid packets on this port (both IN + OUT).
Without this, invalid packets (e.g. asymmetric routing return traffic) are accepted by matching rules.
@neo:ctinvdrop
Expands to:
| Direction | Action | Comment |
|---|---|---|
in |
DROP |
@neo:ct invalid |
out |
DROP |
@neo:ct invalid |
Extension vs decorator are now separate namespaces: use
@neo:ctinvdroponly on Finger carriers (sugar), use@neo:ct invalidonly on real rules (decorator). They produce similar behavior but are written in different places — the decorator form lets you drop invalid packets only when a specific match triggers, rather than unconditionally.
The compilation pipeline is:
- Sugar expansion (afterwards only
@neo:disable/@neo:isolatedremain as native extension rules). - Parser + decorator → IR.
- IR → backend (nftables or OVS flows).
- Apply.
pvefw-neo --apply # apply rules (auto-dispatch nft + OVS)
pvefw-neo --dry-run # print generated nftables ruleset
pvefw-neo --dump-ir # print IR (debug)
pvefw-neo --dump-ovs vmbr2 # print OVS flows for a bridge
pvefw-neo --flush # remove all pvefw-neo state
pvefw-neo --preflight-check # verify host.fw enable=0 + nftables=1When a rule can't be compiled — either by the front-end validator
(family mismatch, rateexceed + ACCEPT, ...) or by nft / ovs-ofctl
rejecting it at load time — pvefw-neo auto-disables the rule:
-
The
.fwline is rewritten to prepend|(PVE's disable marker), so the WebUI checkbox visibly flips to unchecked. -
A single line is appended to
/var/log/pve-firewall.logso the VM → Firewall → Log tab shows, for example:[pvefw-neo] invalid rule #8 disabled, reason: <nft/ovs error or validator explanation> -
The remaining (valid) rules keep working — the bad rule is the only thing that doesn't make it into nft/OVS.
Reverse-routing the backend error to the right source rule uses:
- nft: each rule carries
comment "vm<vmid>-line<N>". nft's stderr echoes the failing rule verbatim, so parsing is a simple substring match. - OVS: each flow's cookie is
0x4E30<48-bit hash(source_id)>; each meter ID is0x4E30<16-bit hash>.ovs-ofctlprints<flows-file>:<N>: <reason>, we read that line, extract the cookie, look up the source_id.
To recover: edit or delete the rule in the WebUI and re-check the checkbox. The next apply will retry; if the rule is still broken it gets quarantined again and a new log line appears.
| Limitation | Reason |
|---|---|
REJECT becomes DROP |
bridge family and OVS don't support REJECT. Peer times out. |
Finger macro reserved as sugar carrier |
TCP/79 is unused in practice. |
| OVS isolation needs ≥2 isolated ports to take effect | "Two isolated ports cannot communicate" — a single isolated port is meaningless. |
@neo:rateexceed only on @neo:stateless |
Stateful + rate-limit semantics can't be cleanly expressed in the OVS meter model. (Stateless mcast_limit on OVS is implemented via OF1.3 meters; pvefw-neo auto-adds OpenFlow13 to the bridge's protocols list when needed.) |
| OVS backend expands ipsets via CIDR pre-subtraction | Flow table size grows with ipset member count. Small sets are fine, large ones may be slow to compile. |
@neo: tags live in the comment field |
PVE WebUI's comment field has a length limit. Split complex rules across multiple lines. |
systemctl status pvefw-neo
journalctl -u pvefw-neo -f
pvefw-neo --dump-ir # backend-agnostic IR
pvefw-neo --dry-run # nftables text
pvefw-neo --dump-ovs vmbr2 # OVS flows for a bridge
# Inspect what actually got installed
nft list table bridge pvefw-neo
ovs-ofctl dump-flows vmbr2 | grep 'cookie=0x4e30'
ovs-ofctl -O OpenFlow13 dump-meters vmbr2 # rate-limit meters
# Inspect the quarantine audit trail (per-VM in WebUI Log tab)
tail -50 /var/log/pve-firewall.log | grep pvefw-neo
pvefw-neo --flush && systemctl restart pvefw-neoSee LICENSE.