Backend triggers: resolve dependencies by their real container name - #124
Backend triggers: resolve dependencies by their real container name#124antoncxx wants to merge 13 commits into
Conversation
59f0a21 to
2c271dc
Compare
Lets a backend-trigger dependency chain declare a literal Docker container name (chain = ["redis"]) instead of a purpose-built DNS alias (chain = ["redis.nullnet.com"]). The client pre-seeds a placeholder /etc/hosts entry for the dependency's name before any packet is observed, so a bare name still produces a real first packet for NFQUEUE to catch; the existing DNAT redirect is unaffected since it never inspected the original destination address. Also disambiguates two dependencies that share a trigger port (e.g. two plain-HTTPS deps, both 443) by giving each its own deterministic placeholder address and widening TriggersState/DNAT to key on destination as well as source + port, and re-seeds the placeholder on teardown instead of deleting it, so an idle-torn-down chain can re-trigger. True multi-chain-per-port support (two different names sharing a port) is tracked separately — it needs ServiceInfo's trigger storage to change shape, which also touches the admin HTTP API and config-reload diffing.
portal calls dep-a and dep-b by their real Docker container name (no *.nullnet.com alias) and combines both responses into HTML. Each container sits on its own isolated Docker network so there's no path between them except the one nullnet builds on demand — proving the placeholder-seeded trigger actually does the work rather than Docker's own embedded DNS. dep-a (80) and dep-b (8080) use different real ports since exercising two dependencies on the same port needs the still-open multi-chain-per-port follow-up (not this commit).
ServiceInfo.triggers moves from one chain per port to a list of chains per port, disambiguated at trigger time by chain[0] (target_name) via the new chain_for() selector: a single chain on a port is used regardless of target_name (unchanged behavior), multiple chains require an exact match. Config validation rejects two chains on the same port with the same chain[0] outright, since nothing could ever tell them apart. Client-side port_to_target is now keyed to a list of targets per port; the NFQUEUE listener's resolve_target() matches the packet's observed destination against each candidate's deterministic placeholder address, falling through to pass-not-guess if none match. This is the piece that closes out the disambiguation groundwork laid in the previous commit. Also updates the admin HTTP API and UI (Services.tsx/types.ts) to the new per-port chain list, and extends the demo stack so dep-a/dep-b share port 80 instead of using distinct ports, actually exercising the disambiguation path end to end.
Add nullnet-proxy setup and a Host-header curl example alongside the existing direct host-port test, since portal is now declared proxy-reachable (timeout = 30). Keeping both gives a way to tell a backend-trigger problem apart from a proxy problem: if the direct port works but the Host-header request doesn't, the issue is in the proxy path, not the one this demo exists to exercise.
collapsible_if in the vxlan-teardown DNAT-removal check, and a manual range check in placeholder.rs's test that clippy prefers as (1..=254).contains(&d).
f868bd5 to
a5deaa6
Compare
portal/dep-a/dep-b now share Compose's default network like a normal multi-service stack. Verified empirically that nullnet still tunnels the traffic rather than falling through to Docker's embedded DNS: /etc/hosts is checked before DNS (so nullnet's entry answers first), and the NFQUEUE/DNAT interception matches on (source container, destination port) regardless of what a name resolved to. Documents both the reasoning and how to actually confirm it (watch for the tunnel, not just the HTTP response) rather than assert isolation is required.
|
Thanks @antoncxx looks good, just two things:
|
|
Save rejections aren't always parse/syntax failures - semantic validation (e.g. the trigger placeholder-collision check) and cross-stack port conflicts hit the same UNPROCESSABLE_ENTITY path. The specific reason is already shown in full next to the badge; the badge text was just misleading.
… address Two distinct chain[0] names sharing a port were already required to be literally distinct, but the client disambiguates a same-port first packet by hashing chain[0] into a placeholder /etc/hosts address, and that hash isn't collision-free. Two different names landing on the same address are just as undetectable to the client as a literal duplicate would be, so services_map() now rejects that case too at config-validation time (both the startup loader and the /api/config save path), with a message naming the two colliding triggers so the UI can show the real reason. The hashing logic itself moves into nullnet-grpc-lib::placeholder so nullnet-client and nullnet-server share one implementation and can never disagree on the mapping.
|
Added config validation
This runs in two places that both call |
TriggersState keys on container name, which is stable across a Docker restart or recreate — but the sandbox isn't, so a stale Active entry (no expiry, unlike Pending) would short-circuit decide_verdict straight to Accept for the new instance's packets, skipping re-trigger entirely, even though the DNAT/tunnel it points at no longer matches anything real. Wire the existing docker-events watcher (which already does a full BridgeIpCache rebuild on every container start/die) to also purge that container's TriggersState entries via a new forget_container, so a restarted/recreated container gets a clean slate without requiring a manual nullnet-client restart.
Summary
Lets a backend-trigger dependency chain declare a literal Docker container
name (
chain = ["redis"]) instead of a purpose-built DNS alias(
chain = ["redis.nullnet.com"]). Also adds support for two dependenciesthat happen to share a real port (e.g. two plain-HTTPS deps, both 443),
which the first version of this change left as a known gap and later
closed out.
Why
Backend-trigger dependency chains only build the first time the initiator
actually opens a connection on the watched port — an NFQUEUE listener holds
that first packet, reports it to the server, the server builds the tunnel,
then the packet is released once DNAT is in place. That only works if a
real packet leaves the container in the first place. A bare name like
redisfails before it gets that far: with nothing to resolve it to, theapp's
connect()never succeeds, no packet is ever sent, and the triggernever fires. Today's workaround is a purpose-built alias
(
redis.nullnet.com) backed by a pre-provisioned*.nullnet.comDNSwildcard, whose only job is giving the name something to resolve to.
This PR removes that workaround: the client pre-seeds a placeholder
/etc/hostsentry for the dependency's literal name before any packet isobserved, so the bare name resolves, a real first packet gets sent, and the
existing NFQUEUE →
backend_trigger→ tunnel-setup → DNAT flow runsunchanged after that (it never inspected the destination address to begin
with).
What changed
Proto (
nullnet_grpc.proto)ServiceTrigger.trigger_ports:repeated TriggerPort { port, target_name }replaces the old flat
portslist, so the server tells the client notjust which port to watch but which literal name (
chain[0]) it resolves.ServiceTrigger.initiator_container: the real container name the clientshould seed, resolved server-side from data it already had.
BackendTriggerRequest.target_name: the client reports back which nameits placeholder's destination address belonged to, so the server can pick
the right chain when a port has more than one.
Server
ServiceInfo.triggersmoves fromHashMap<u16, Vec<String>>(one chainper port) to
HashMap<u16, Vec<Vec<String>>>— more than one chain canshare a port now. New
chain_for(port, target_name)selector: a singlechain on a port is used regardless of
target_name(unchanged behaviorfor every existing config); multiple chains require an exact match — no
guessing when ambiguous.
chain[0]outright (nothing could ever tell them apart).http_server/services.rs) and UI(
Services.tsx/types.ts) updated to the new per-port chain list.Client
placeholder.rs(new): deterministicname → IPin203.0.113.0/24(RFC 5737 TEST-NET-3 — reserved, never a real host, never on-link for a
container's own subnet, so it always falls through to the container's
default route). Range is env-overridable via
TRIGGER_PLACEHOLDER_CIDR.loop (same ~10s /
docker eventsfast-path cadence already used for thewatched-port ipset) — idempotent, self-heals across container restarts
(Docker wipes
/etc/hostson every start).TriggersStateand DNAT (commands/dnat.rs) both widened to key ondestination address as well as source + port, so two chains sharing a
port get independent state and independent
-d-scoped DNAT rules insteadof clobbering each other.
ipv4_flow,already existed for the egress listener) and, when a port has more than
one candidate target, disambiguates by matching the packet's observed
destination against each candidate's own deterministic placeholder
address — passing through unaltered rather than guessing if nothing
matches.
backend-trigger entries, so an idle-torn-down chain can re-trigger on the
next connection attempt instead of dead-ending exactly like an unseeded
bare name would. Proxy-dependency mappings are unaffected — a fresh proxy
request rebuilds the chain and writes the real mapping before forwarding,
so there's no gap to cover there.
Demo (
demo/name-resolution/)Three-container stack (
portal→dep-a,dep-b) exercising the wholething end to end, reachable through
nullnet-proxybyHostheader.dep-aanddep-bdeliberately share the same real port (80) — twotrigger chains on one port, told apart by
chain[0]— so the demo actuallyruns the disambiguation path, not just the base case. Each container sits
on its own isolated Docker network: no shared network, no path between them
except the one nullnet builds on demand, which is what proves the
placeholder-seeded trigger is doing the work rather than Docker's own
embedded DNS resolving things directly.
Out of scope
resolving any name) — the backend-trigger path already handles this for
free (it never inspected the destination address), but proxy-dependency
edges would need the same DNAT fallback backend triggers have; not
addressed here.