Backend triggers: resolve dependencies by their real container name - #124
Open
antoncxx wants to merge 7 commits into
Open
Backend triggers: resolve dependencies by their real container name#124antoncxx wants to merge 7 commits into
antoncxx wants to merge 7 commits into
Conversation
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.
antoncxx
force-pushed
the
feature/backend-trigger-container-names
branch
from
July 30, 2026 01:20
59f0a21 to
2c271dc
Compare
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).
antoncxx
marked this pull request as ready for review
July 30, 2026 01:53
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.