Skip to content

feat(marketplace): the filter that stops one customer being another - #367

Merged
v0l merged 2 commits into
masterfrom
feat/marketplace-node-firewall
Aug 7, 2026
Merged

feat(marketplace): the filter that stops one customer being another#367
v0l merged 2 commits into
masterfrom
feat/marketplace-node-firewall

Conversation

@v0l

@v0l v0l commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Increment 4c2 of the marketplace work (work/marketplace.md), on top of #366.

4c1's network namespace already settled the anti-LAN half of this by construction: there is no interface from br-lnvps to the operator's network, so there is no rule to write. This is the half no topology can express.

What it enforces

A guest may source only the addresses LNVPS assigned it. The route server's AllowedIPs stops one node claiming another's addresses, but it cannot see inside a node — both guests' addresses legitimately belong to that node's peer, so guest A wearing guest B's address is indistinguishable from the far end. Where LNVPS recorded a MAC (it did; it assigned it) the address is bound to it.

Guests may not reach each other at layer 2. They share one bridge and proxy ARP tells each of them that every address is on-link, so without this a tenant can ARP-poison or ND-poison their neighbours — an attack that never reaches the IP layer the rest of the ruleset filters at. Dropping the bridge's forward hook does not disconnect them; it forces their traffic to be routed by the node, where it can be checked, which is exactly what they would get if they were on two different nodes. Same network either way.

TCP MSS clamped to the path MTU, not to a number baked into the rules — the tunnel's MTU can change under the filter, and a stale clamp hangs large transfers exactly like no clamp at all.

Three decisions worth reviewing

  • The ruleset is owned wholesale and swapped atomically. The daemon never appends to an operator's chains: it renders a complete table and replaces it in one transaction. No window in which a guest is unfiltered, and no node carrying a month of duplicate rules.
  • The machine states which ruleset it is running, and the daemon believes it. A tag is rendered into a rule comment and read back out of nft list / iptables-save. An operator who flushes the table by hand gets it rebuilt on the next refresh — a daemon that trusted its own memory would go on reporting a filter that no longer existed, which is this increment's own failure mode arrived at from the other side. It is also what makes a refresh a genuine no-op.
  • A node whose filter is not loaded reports itself unhealthy. An unfiltered node is one where any guest can be any other; better it carries nobody. 4c3's health gate gets this for free.

nft where it exists, iptables where it does not, detected at runtime and named in status — the premise of the marketplace is hardware LNVPS did not choose. Status also carries the spoof drop counter, the one number here that is about a customer rather than a node: a guest that is spoofing is either compromised or hostile, and LNVPS would rather learn it from a counter than from an upstream abuse report.

Proof

The end-to-end harness proves the drop by counter, not by ping. The guest gives itself a second address — as any customer with root in their own VM can — and the node's spoof counter is asserted to move. A spoofed packet gets no reply in any case for want of a return route, so a failed ping would have proved nothing at all.

sudo ./scripts/tunnel-e2e.sh

Deferred

Per-port isolated flags and per-tap filtering land in increment 5, where the daemon starts creating the taps. Binding an address to a port is stronger than binding it to a MAC, which a guest chooses; MAC binding plus L2 isolation is what is available until the daemon owns the ports.

Testing

  • 33 new tests in lnvps_node::fw; workspace suite green (cargo test --workspace --exclude lnvps_e2e -- --test-threads=1).
  • Harness green, including the spoof-drop and layer 2 assertions.
  • 100% function coverage on fw.rs apart from SystemFirewall, which needs root and is covered by the harness — the same precedent as net::Kernel.

v0l added 2 commits August 7, 2026 10:38
Increment 4c2. The namespace in 4c1 already settled the anti-LAN half of this
by construction — there is no interface from `br-lnvps` to the operator's
network, so there is no rule to write. What is left is what no topology can
express.

**A guest may source only the addresses LNVPS assigned it.** The route server's
`AllowedIPs` stops one node claiming another's addresses, but it cannot see
inside a node: both guests' addresses legitimately belong to that node's peer,
so guest A wearing guest B's address is invisible from the far end. Where LNVPS
recorded a MAC — it did, it assigned it — the address is bound to it, so a
spoofing guest has to get both right and still cannot use an address belonging
to a guest on another node.

**Guests may not reach each other at layer 2.** They share one bridge, and
proxy ARP tells each of them that every address is on-link, so without this a
tenant can ARP-poison or ND-poison their neighbours — an attack that never
reaches the IP layer the rest of the ruleset filters at. Dropping the bridge's
forward hook does not disconnect them: it forces their traffic to be *routed*
by the node, where it can be checked, which is exactly what they would get if
they were on two different nodes. Same network either way, which is the point.

**TCP MSS is clamped to the path MTU**, because a guest that ignores path MTU
discovery otherwise gets a connection that opens and then hangs — a much worse
failure than a slightly small segment.

Three decisions worth the words:

- **The ruleset is owned wholesale and swapped atomically.** The daemon never
  appends to an operator's chains: it renders a complete table and replaces it
  in one transaction, so there is no moment when a guest is running unfiltered,
  and a node up for a month is not carrying a month of duplicate rules.
- **The machine states which ruleset it is running, and the daemon believes
  it.** A tag is rendered into a rule comment and read back out of the kernel,
  so an operator who flushes the table by hand gets it rebuilt on the next
  refresh. A daemon that trusted its own memory would keep reporting a filter
  that no longer existed — the same failure this increment exists to prevent,
  arrived at from the other side.
- **A node whose filter is not loaded reports itself unhealthy.** An unfiltered
  node is one where any guest can be any other; better it carries nobody.

`nft` where it exists and `iptables` where it does not, detected at runtime and
named in status, because the whole premise of the marketplace is hardware LNVPS
did not choose. Status also carries the spoof drop counter — the one number
here that is about a customer rather than a node, and one LNVPS would rather
read from a counter than from an upstream abuse report.

The end-to-end harness proves the drop rather than the ping: the guest gives
itself a second address, as any customer with root in their own VM can, and the
counter is asserted to move. A spoofed packet gets no reply in any case for want
of a return route, so a failed ping would have proved nothing at all.
The filter previously formatted `nft` syntax into a string, shelled out to load
it, and then read it back by scraping `nft list` output — a node's safety
resting on the text format of whichever nftables version an operator happens to
have installed.

Rules are now built as `nftables` crate schema objects and exchanged with the
kernel as JSON in both directions. Nothing here formats or parses nft syntax:
observation walks typed rules and sets, so the drop counter comes from the
counter statement on the rule that carries the tag rather than from a regex
over a dump, and a set's contents are its contents.

The typed path immediately earned it. Two rulesets that read perfectly well as
text were rejected by the kernel the first time they were loaded for real: an
empty set element list, and an ICMP type list, which nftables reads as a
*bitmask* — an ICMP type is an enumeration, so it needs an anonymous set. Both
are now expressed correctly; neither was visible in any unit test, because
"does the kernel accept this" is not a question you can ask a string.

**iptables support is dropped.** It cannot express the layer 2 rule at all —
that is `ebtables`, a third tool with a third syntax — it has no typed
exchange, and a second code path enforcing "the same" policy is a second code
path to get subtly wrong on machines nobody is testing. Debian has shipped
nftables by default since Buster; a machine without it is refused, which is the
right answer for a machine that cannot filter its guests at all.

`FirewallState.backend` becomes `available`, since there is now one answer to
the question of which filter a node uses.
@v0l

v0l commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Reworked onto the nftables crate, and iptables is gone.

Typed both ways

Rules were being formatted into nft syntax as a string, shelled out, and then read back by scraping nft list output — a node's safety resting on the text format of whichever nftables version an operator happens to have. They are now built as schema objects and exchanged with the kernel as JSON in both directions. Observation walks typed rules and sets: the drop counter comes from the counter statement on the rule carrying the tag, not from a regex over a dump.

It earned that immediately. Two rulesets that read perfectly well as text were rejected by the kernel the first time they were loaded for real:

  • an empty set element list, which nftables refuses — and every node has one on its first day;
  • an ICMP type list, which nftables reads as a bitmask. An ICMP type is an enumeration, so it needs an anonymous set. As a list it does not load at all, which is the lucky outcome.

Neither was visible in a unit test, because "will the kernel accept this" is not a question you can ask a string.

iptables dropped

Per your call. Worth recording why it was the right one: it cannot express the layer 2 rule at all — that is ebtables, a third tool with a third syntax — it has no typed exchange, and a second code path enforcing "the same" policy is a second code path to get subtly wrong on machines nobody is testing. Debian has shipped nftables by default since Buster; a machine without it is refused, which is the correct answer for a machine that cannot filter its guests.

FirewallState.backend becomes available, since there is now one answer to which filter a node uses.

Still true

One nft invocation remains, from a thread that has entered the data plane namespace — the crate's own helpers spawn on whichever runtime thread they land on, which would load the ruleset into the operator's namespace instead. Everything it is given and everything it returns is JSON.

Harness green (both scenarios, including the spoof-drop counter and layer 2 isolation), workspace suite green, fw.rs at 100% function coverage bar SystemFirewall, which needs root and is covered by the harness.

@v0l
v0l merged commit 259030c into master Aug 7, 2026
10 checks passed
@v0l
v0l deleted the feat/marketplace-node-firewall branch August 7, 2026 10:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant