feat(marketplace): the filter that stops one customer being another - #367
Conversation
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.
|
Reworked onto the Typed both waysRules were being formatted into 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:
Neither was visible in a unit test, because "will the kernel accept this" is not a question you can ask a string. iptables droppedPer your call. Worth recording why it was the right one: it cannot express the layer 2 rule at all — that is
Still trueOne Harness green (both scenarios, including the spoof-drop counter and layer 2 isolation), workspace suite green, |
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-lnvpsto 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
AllowedIPsstops 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
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.nftwhere it exists,iptableswhere 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.
Deferred
Per-port
isolatedflags 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
lnvps_node::fw; workspace suite green (cargo test --workspace --exclude lnvps_e2e -- --test-threads=1).fw.rsapart fromSystemFirewall, which needs root and is covered by the harness — the same precedent asnet::Kernel.