Skip to content

Blog ·

An empty Proxmox ipfilter set means deny all

Enable ipfilter on a Proxmox VM whose NIC has no addresses and the card goes completely dark: no error, no log line. In the PVE firewall, absence is a value.

By Provibr

Anti-spoofing on a Proxmox VM is one checkbox: ipfilter in the firewall options. Proxmox then only lets traffic through if the source address is in that VM's ipfilter-netX ipset. It is a good feature and you should use it.

It also has one failure mode that produces no error, no log line, and no way to tell from the API that anything is wrong: if the ipset is empty, nothing is allowed.

Not "no filter applied". Not "everything allowed, there is nothing to check against". Empty means deny.

Why that is worse than it sounds

The obvious version (you enable ipfilter and forget to populate the set) is a self-inflicted wound you would find in a minute.

The version that bites is a VM with more than one network card.

Your address management knows about the addresses on net0. That is where ipconfig0 puts them, so that is the NIC your IPAM has a record for. Somebody adds a second card, net1, for a private network or a storage VLAN. Your automation has no addresses for net1, so it creates ipfilter-net1 empty, or does not create it at all.

Turn ipfilter on for that VM and net0 keeps working exactly as expected, while net1 goes completely dark. The VM is up. The console works. qm config looks correct. The firewall log is silent, because the drop is the ipfilter mechanism itself, not a rule you can see.

The customer reports that their private network stopped working, and nothing anywhere says why.

> Measured against Proxmox VE 9.2.4 in August 2026, on a disposable VM inside a dedicated test range.

The rule we settled on: all NICs or none

Because there is no partial-safe state, the only honest policy we could find is all-or-nothing per VM:

Enable ipfilter only when every NIC on the machine has at least one address to put in its set.

Not "enable it and cover what we can". A partially covered VM is a VM where one card silently stops passing traffic, and no amount of good intent in the calling code makes that a reasonable default.

Concretely, in our implementation:

  1. The platform sends the complete address list for every NIC, always; never a delta.
  2. The platform decides whether ipfilter can be enabled at all, based on whether every NIC is covered.
  3. The agent that talks to the hypervisor checks again independently, and if it finds an uncovered NIC it refuses to enable ipfilter and reports the NIC by name.
  4. That name goes straight into the UI, in a field called something like ipfilterSkipped: ["net1"].

Step 3 exists because step 2 is a decision made from a database, and step 3 is a decision made from what the hypervisor actually has. Those can disagree, and when they do, the hypervisor is right.

Step 4 matters more than it looks. "Firewall not applied" is a useless message. "Firewall not applied: net1 has no addresses assigned" is something an operator fixes in thirty seconds.

The rules Proxmox writes, and why order is the safety

When ipfilter goes on, the rule set we install per covered NIC is:

text
IN  ACCEPT -dest   +ipfilter-net0   # nolog
OUT ACCEPT -source +ipfilter-net0   # nolog
IN  DROP                            # nolog
OUT DROP                            # nolog

The DROPs must come last. That is not a style preference: a DROP above an ACCEPT closes the VM completely, which is the same outage as the empty-set case, arrived at from the other direction.

This has a consequence for how you write the reconciler, and it caught us:

Plan one step per round. Do not compute positions ahead.

Proxmox rules are positional. The moment you insert or delete one, every position after it shifts. A planner that works out "insert at 0, insert at 1, insert at 2, insert at 3" from the current state is correct only until its own first write lands. After that it is writing into a list that no longer matches the plan, and the way that goes wrong is a DROP that ends up above an ACCEPT.

So: read state, decide the single next operation, apply it, read state again. It is more round trips and it is the only version that cannot half-apply.

The other half of that is knowing which rules are yours. We put a fixed marker in the comment field of every rule we manage. Anything without the marker belongs to the customer and is never touched, including when the customer turns the firewall off, which removes our rules and leaves theirs alone. We verified this on a real cluster: a hand-added customer rule survived a full apply, a re-apply, and a disable.

Three more places where absence is a value

Once you notice that Proxmox's firewall API encodes meaning in what is not there, you start finding it elsewhere.

An empty firewall log is not an empty list. Query the log on a VM that has produced no entries and you do not get []. You get:

json
[{"t":"no content","n":1}]

Render that naively and your UI shows a log line reading "no content" and never displays its empty state. Filter it explicitly.

Worth knowing alongside this: the default rules above are all nolog, deliberately. They fire constantly and would drown everything else. Which means if you offer a log view, it will be structurally empty unless you also let operators set the log level per rule.

/firewall/options on an unconfigured VM returns only a digest. No enable, no ipfilter, no defaults filled in:

json
{"data":{"digest":"a1b2c3..."}}

If your code reads options.enable and finds undefined, that is not "firewall is off" as a fact you can act on. It is "this VM has no firewall configuration yet". Treat the missing key as unknown, not false.

That digest, incidentally, is the optimistic lock. Send it back with your write and Proxmox rejects the write if anything changed underneath you. Use it. One practical note if you are writing tests: any operation that touches the rule set changes the digest, so a test that does two writes with one stale digest will be correctly refused, and it will look like your locking is broken when it is working.

An omitted key is not "off" when the schema has a default. Not firewall, but the same class of mistake, and it cost us a bug: ciupgrade in cloud-init has default = 1 in the Proxmox schema. Leaving it out does not mean "do not upgrade". It means "upgrade". So an unchecked box in your UI has to be sent as an explicit ciupgrade=0, or the checkbox is lying.

The general form: before you decide that omitting a key means "off", check whether the schema has a default. In Proxmox it often does.

What we did not test

All of the above is measured against Proxmox VE 9.2.4 in August 2026. We have not re-measured against 8.x. The one version-dependent thing we did find in this area is that ciupgrade does not exist at all before PVE 8.1: sending it there is a hard error, so if you support older clusters you need a fallback rather than a failure.

We also have not tested ipfilter behaviour with IPv6 sets, or with more than two NICs on one VM. The empty-set-means-deny behaviour has no reason to differ, but we have not confirmed it.

This came out of building Provibr's Proxmox integration, where "the firewall silently closed one of the customer's cards" is exactly the class of bug that has to be impossible rather than unlikely. Provibr is a server management panel for hosting providers.

Start with one host and one agent

Create an account, install the agent with a single command and connect your first Proxmox host. The trial runs for 14 days and asks for no payment details.