Skip to content

Backend triggers: resolve dependencies by their real container name - #124

Open
antoncxx wants to merge 7 commits into
NullNet-ai:mainfrom
antoncxx:feature/backend-trigger-container-names
Open

Backend triggers: resolve dependencies by their real container name#124
antoncxx wants to merge 7 commits into
NullNet-ai:mainfrom
antoncxx:feature/backend-trigger-container-names

Conversation

@antoncxx

@antoncxx antoncxx commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

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 dependencies
that 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
redis fails before it gets that far: with nothing to resolve it to, the
app's connect() never succeeds, no packet is ever sent, and the trigger
never fires. Today's workaround is a purpose-built alias
(redis.nullnet.com) backed by a pre-provisioned *.nullnet.com DNS
wildcard, whose only job is giving the name something to resolve to.

This PR removes that workaround: the client pre-seeds a placeholder
/etc/hosts entry for the dependency's literal name before any packet is
observed, so the bare name resolves, a real first packet gets sent, and the
existing NFQUEUE → backend_trigger → tunnel-setup → DNAT flow runs
unchanged 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 ports list, so the server tells the client not
    just which port to watch but which literal name (chain[0]) it resolves.
  • ServiceTrigger.initiator_container: the real container name the client
    should seed, resolved server-side from data it already had.
  • BackendTriggerRequest.target_name: the client reports back which name
    its placeholder's destination address belonged to, so the server can pick
    the right chain when a port has more than one.

Server

  • ServiceInfo.triggers moves from HashMap<u16, Vec<String>> (one chain
    per port) to HashMap<u16, Vec<Vec<String>>> — more than one chain can
    share a port now. New chain_for(port, target_name) selector: a single
    chain on a port is used regardless of target_name (unchanged behavior
    for every existing config); multiple chains require an exact match — no
    guessing when ambiguous.
  • Config validation rejects two chains on the same port with the same
    chain[0] outright (nothing could ever tell them apart).
  • Admin HTTP API (http_server/services.rs) and UI
    (Services.tsx/types.ts) updated to the new per-port chain list.

Client

  • placeholder.rs (new): deterministic name → IP in 203.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.
  • Placeholder seeding is wired into the existing declare-services reconcile
    loop (same ~10s / docker events fast-path cadence already used for the
    watched-port ipset) — idempotent, self-heals across container restarts
    (Docker wipes /etc/hosts on every start).
  • TriggersState and DNAT (commands/dnat.rs) both widened to key on
    destination address as well as source + port, so two chains sharing a
    port get independent state and independent -d-scoped DNAT rules instead
    of clobbering each other.
  • NFQUEUE listener carries the destination IP end to end (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.
  • Teardown re-seeds the placeholder instead of deleting it for
    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 (portaldep-a, dep-b) exercising the whole
thing end to end, reachable through nullnet-proxy by Host header.
dep-a and dep-b deliberately share the same real port (80) — two
trigger chains on one port, told apart by chain[0] — so the demo actually
runs 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

  • Hardcoded-IP initiators (an app that dials a literal IP instead of
    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.
  • Egress/forward-proxy paths are untouched.

antoncxx added 5 commits July 29, 2026 19:28
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
antoncxx force-pushed the feature/backend-trigger-container-names branch from 59f0a21 to 2c271dc Compare July 30, 2026 01:20
antoncxx added 2 commits July 29, 2026 21:28
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 antoncxx changed the title Backend trigger container names Backend triggers: resolve dependencies by their real container name Jul 30, 2026
@antoncxx
antoncxx marked this pull request as ready for review July 30, 2026 01:53
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