Spec: One typed seam through which all NAT state flows
Problem Statement
NAT rule knowledge and the reconcile logic that acts on it are split across two crates with no shared interface. The auto-discover forwarding sync reads iptables-save text in a second crate to guess which forwarding rules are stale, so a comment-format change in the natmap daemon's rule builder silently breaks staleness detection elsewhere — and multiport rules are never detected as stale at all. The client auto-discover uses to reach the daemon round-trips through the CLI's parse layer and string-matches "409"/"404"/"Container not found" off flattened error text. Intent is hidden behind a misnamed bool: forwarding passes preserve_src_ip into a parameter called no_masquerade, which is never consumed in rule building. The apply-a-mapping flow (resolve port → install → persist → rollback) is re-implemented in parallel paths in both daemons, so the last five bug fixes each had to be applied across two or more sites.
Solution
Put every NAT operation behind one typed client interface in the natmap crate, expose the daemon's live rules read-only over that interface, and collapse each daemon's duplicated apply logic into one primitive. Auto-discover's forwarding sync reads reported rules and reconciles against them; the daemon keeps ownership of the rules it creates and reports what is live. Vocabulary at the seam aligns to the domain term preserve_src_ip.
User Stories
- As the auto-discover forwarding sync, I want to read the live NAT rules from the natmap daemon through the typed client, so that I no longer parse iptables-save text in my own crate.
- As the forwarding sync, I want the reported rules to carry protocol and multiport ports, so that stale multiport forwarding rules are finally cleaned up.
- As the forwarding sync, I want to delete stale rules with their real attributes, so that cleanup stops guessing hairpin and preserve_src_ip.
- As the forwarding sync, I want typed errors from the natmap client, so that the 409/404 string-matching disappears.
- As the forwarding sync, I want
preserve_src_ip intent visible at the call site, so that the no_masquerade collision disappears and the vocabulary matches CONTEXT.md.
- As the forwarding sync, I want my own error handling to stop swallowing rule deletions, so that a failed cleanup is visible instead of silent.
- As the auto-discover daemon, I want one
sync_service primitive used by both the sync command and the container-event path, so that a behavior change applies once, not twice.
- As the natmap daemon, I want one
ensure_docker_mapping primitive used by reload, on_container_start and reconcile, so that allocate→install→rollback is implemented once per rule kind.
- As a test, I want to drive the forwarding reconcile against an in-memory natmap adapter, so that the hot-spot logic is unit-tested without privileged containers.
- As a test, I want the GET /rules parsing tested in the crate that owns the format, so that comment/rule drift is caught where it happens.
- As an operator, I want the natmap and auto-discover CLIs to keep working unchanged, so that the refactor does not change the UX.
Implementation Decisions
- Typed client module in the natmap crate (public
client). It calls the existing request_json socket primitive directly — the wire protocol is already HTTP over the Unix socket — and removes the Cli-struct round-trip through run_cli. Methods mirror the daemon's router: dnat, hairpin, snat, policy-route, port-mapping add/remove/remap/clear, and the new rules read.
- Typed config in, positional bools out. Client methods take the existing typed config structs (
DnatConfig, HairpinRequest, PolicyRouteRequest, mapping requests). dnat takes a DnatConfig plus an explicit delete flag.
DnatConfig.no_masquerade renamed to preserve_src_ip across the seam, matching the domain term in CONTEXT.md. The flag remains metadata (no iptables change) and is threaded through unchanged; policy-route and the hairpin lan_cidr stay separate features triggered by the same flag.
- Typed error enum in the natmap crate.
request_json learns to surface status codes; the client maps them to variants (Conflict, ContainerNotFound, NotFound, …). Auto-discover matches variants, not strings.
- New
GET /rules endpoint returning structured live-rule models ({ kind, ext_ip, int_ip, ports, proto }) parsed from the daemon's own get_rules output per NATMAP chain and PREROUTING/FORWARD, attributed by comment (natmap:dnat:, natmap:hairpin:, natmap:<container>:<port>), including multiport (--dports). Distinct from GET /mappings (persisted state).
- Forwarding reconciles against reported rules.
find_stale_rules and parse_dnat_rule are deleted from auto-discover; the sync fetches live rules via the typed client, diffs its desired set, and deletes stale groups with real attributes. The delete path stops swallowing errors with .ok().
- One
sync_service(target, resolved) primitive in auto-discover, called by both the sync command and the container-event path. The target enum (Container | Local) is the seam where container-vs-local varies; the port-decision step sits behind an internal seam so a later port-authority change can swap it.
ensure_docker_mapping and ensure_static_rule primitives in the natmap daemon, shared by reload, on_container_start and the reconcile routines. Orchestrators stay; the per-rule allocate→install→rollback step is implemented once.
- CLI surface unchanged. The CLI keeps dispatching through its existing handlers; only auto-discover's internal client changes.
Testing Decisions
- A good test exercises external behavior through the interface, not internal wiring: the typed client against the daemon's in-process router, the reconcile primitives against an in-memory adapter. No test may shell to real iptables or require a privileged container.
- natmap client: round-trip against the real axum router with fake iptables/ports adapters behind the handlers.
- GET /rules parsing: unit tests in the natmap crate covering single-port, multiport, hairpin, and port-mapping rule text, plus the comment attribution.
- Forwarding reconcile: unit tests driving the sync against an in-memory natmap adapter, asserting the create/delete calls issued for desired-vs-stale sets — including the multiport-stale case that currently fails.
sync_service: unit tests with in-memory natmap and Consul adapters covering docker and local targets and the event-loop vs sync entry points.
ensure_docker_mapping / ensure_static_rule: unit tests with a fake iptables adapter, covering the allocate→install→rollback ordering and the recent bug-fix scenarios (stale deallocate, untracked containers, IP re-verify).
- Prior art: existing proptests on rule parsing and grouping, the iptables arg-builder unit tests, and the api.rs handler tests that skip on real iptables.
Out of Scope
- Candidate 4 (single port authority) and candidate 5 (container inspection in lab-lib) from the architecture review — separate specs, landed later; the internal port-decision seam in
sync_service is their future attachment point.
- Moving rule reconciliation into the natmap daemon — explicitly rejected for now, recorded in ADR-0001.
- Changing the daemon's flush-and-reinstall reconcile strategy, or making the daemon's own reconcile consume GET /rules.
- Any CLI or UX changes.
Further Notes
- ADR-0001 records the seam decision: the daemon reports live rules read-only; reconciliation stays with each daemon.
- CONTEXT.md defines the vocabulary this spec uses: forwarding, forwarding sync, preserve_src_ip, hairpin, port mapping, live rule, NAT rule, reconcile, natmap daemon.
- Implementation order is set by the blocking edges declared in the tickets: the typed client first, then the rules endpoint and the sync/ensure collapses.
Spec: One typed seam through which all NAT state flows
Problem Statement
NAT rule knowledge and the reconcile logic that acts on it are split across two crates with no shared interface. The auto-discover forwarding sync reads
iptables-savetext in a second crate to guess which forwarding rules are stale, so a comment-format change in the natmap daemon's rule builder silently breaks staleness detection elsewhere — and multiport rules are never detected as stale at all. The client auto-discover uses to reach the daemon round-trips through the CLI's parse layer and string-matches"409"/"404"/"Container not found"off flattened error text. Intent is hidden behind a misnamed bool: forwarding passespreserve_src_ipinto a parameter calledno_masquerade, which is never consumed in rule building. The apply-a-mapping flow (resolve port → install → persist → rollback) is re-implemented in parallel paths in both daemons, so the last five bug fixes each had to be applied across two or more sites.Solution
Put every NAT operation behind one typed client interface in the natmap crate, expose the daemon's live rules read-only over that interface, and collapse each daemon's duplicated apply logic into one primitive. Auto-discover's forwarding sync reads reported rules and reconciles against them; the daemon keeps ownership of the rules it creates and reports what is live. Vocabulary at the seam aligns to the domain term
preserve_src_ip.User Stories
preserve_src_ipintent visible at the call site, so that the no_masquerade collision disappears and the vocabulary matches CONTEXT.md.sync_serviceprimitive used by both the sync command and the container-event path, so that a behavior change applies once, not twice.ensure_docker_mappingprimitive used by reload, on_container_start and reconcile, so that allocate→install→rollback is implemented once per rule kind.Implementation Decisions
client). It calls the existingrequest_jsonsocket primitive directly — the wire protocol is already HTTP over the Unix socket — and removes theCli-struct round-trip throughrun_cli. Methods mirror the daemon's router: dnat, hairpin, snat, policy-route, port-mapping add/remove/remap/clear, and the new rules read.DnatConfig,HairpinRequest,PolicyRouteRequest, mapping requests).dnattakes aDnatConfigplus an explicit delete flag.DnatConfig.no_masqueraderenamed topreserve_src_ipacross the seam, matching the domain term in CONTEXT.md. The flag remains metadata (no iptables change) and is threaded through unchanged; policy-route and the hairpinlan_cidrstay separate features triggered by the same flag.request_jsonlearns to surface status codes; the client maps them to variants (Conflict,ContainerNotFound,NotFound, …). Auto-discover matches variants, not strings.GET /rulesendpoint returning structured live-rule models ({ kind, ext_ip, int_ip, ports, proto }) parsed from the daemon's ownget_rulesoutput per NATMAP chain and PREROUTING/FORWARD, attributed by comment (natmap:dnat:,natmap:hairpin:,natmap:<container>:<port>), including multiport (--dports). Distinct fromGET /mappings(persisted state).find_stale_rulesandparse_dnat_ruleare deleted from auto-discover; the sync fetches live rules via the typed client, diffs its desired set, and deletes stale groups with real attributes. The delete path stops swallowing errors with.ok().sync_service(target, resolved)primitive in auto-discover, called by both the sync command and the container-event path. The target enum (Container | Local) is the seam where container-vs-local varies; the port-decision step sits behind an internal seam so a later port-authority change can swap it.ensure_docker_mappingandensure_static_ruleprimitives in the natmap daemon, shared by reload, on_container_start and the reconcile routines. Orchestrators stay; the per-rule allocate→install→rollback step is implemented once.Testing Decisions
sync_service: unit tests with in-memory natmap and Consul adapters covering docker and local targets and the event-loop vs sync entry points.ensure_docker_mapping/ensure_static_rule: unit tests with a fake iptables adapter, covering the allocate→install→rollback ordering and the recent bug-fix scenarios (stale deallocate, untracked containers, IP re-verify).Out of Scope
sync_serviceis their future attachment point.Further Notes