{"id":50114938,"url":"https://github.com/alexcherrypi/anchord","last_synced_at":"2026-05-23T14:01:12.070Z","repository":{"id":355276321,"uuid":"1227213215","full_name":"AlexCherrypi/anchord","owner":"AlexCherrypi","description":"Make a Docker Compose project look like a real server on your LAN — one IP, real client source IPs, no port-mapping gymnastics. For Mailcow, Nextcloud, Matrix, anything self-hosted.","archived":false,"fork":false,"pushed_at":"2026-05-18T04:55:12.000Z","size":432,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-18T06:50:13.544Z","etag":null,"topics":["container-networking","dhcp","dnat","docker","docker-compose","dual-stack","go","golang","homelab","ipv6","linux-networking","macvlan","mailcow","nat","networking","nextcloud","nftables","self-hosted","selfhosted"],"latest_commit_sha":null,"homepage":null,"language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/AlexCherrypi.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null}},"created_at":"2026-05-02T11:18:49.000Z","updated_at":"2026-05-18T04:55:16.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/AlexCherrypi/anchord","commit_stats":null,"previous_names":["alexcherrypi/anchord"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/AlexCherrypi/anchord","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/AlexCherrypi%2Fanchord","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/AlexCherrypi%2Fanchord/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/AlexCherrypi%2Fanchord/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/AlexCherrypi%2Fanchord/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/AlexCherrypi","download_url":"https://codeload.github.com/AlexCherrypi/anchord/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/AlexCherrypi%2Fanchord/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33398391,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-23T04:15:53.637Z","status":"ssl_error","status_checked_at":"2026-05-23T04:15:53.242Z","response_time":53,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["container-networking","dhcp","dnat","docker","docker-compose","dual-stack","go","golang","homelab","ipv6","linux-networking","macvlan","mailcow","nat","networking","nextcloud","nftables","self-hosted","selfhosted"],"created_at":"2026-05-23T14:01:04.955Z","updated_at":"2026-05-23T14:01:12.060Z","avatar_url":"https://github.com/AlexCherrypi.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"# anchord\n\n[![CI](https://github.com/AlexCherrypi/anchord/actions/workflows/ci.yml/badge.svg)](https://github.com/AlexCherrypi/anchord/actions/workflows/ci.yml)\n[![Container](https://img.shields.io/badge/ghcr.io-anchord-blue?logo=docker)](https://github.com/AlexCherrypi/anchord/pkgs/container/anchord)\n[![Go Version](https://img.shields.io/github/go-mod/go-version/AlexCherrypi/anchord)](go.mod)\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)\n[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/AlexCherrypi/anchord)\n\n\u003e One IP per Compose project. No subnet bookkeeping. Real client source IPs.\n\n\u003e **Status (2026-05-20):** in production on a small self-hosted fleet\n\u003e (TrueNAS SCALE host, 6+ Compose stacks, ~14 service-anchors —\n\u003e Mailcow, Authentik, Nextcloud-AIO, Traefik, …). v2 has shipped;\n\u003e v2.x deltas (F-41 through F-46) cover the wrap-pattern,\n\u003e label-selector discovery, runtime-spawned managed service-anchors,\n\u003e port-translating DNAT, and authoritative default-route handling\n\u003e via DHCP Option 3. See [SPEC-v2-DRAFT.md](SPEC-v2-DRAFT.md) plus\n\u003e the per-feature `SPEC-*-DRAFT.md` files for the contracts.\n\n**Built for self-hosted, homelab, and small-fleet workloads** that want\nclassical \"one server, one service\" semantics — Mailcow, Nextcloud,\nMatrix/Synapse, Gitea, anything that historically ran on its own box.\n\n`anchord` is a per-project network anchor for Docker Compose. It gives a\nCompose project a single externally-routable IP — by joining a shared\nDocker macvlan network, optionally refreshing the address via DHCP with a\nstable hostname — and dynamically maintains nftables DNAT rules pointing\nat labelled service-anchor containers, without you ever hard-coding an IP\ninside the project. Fully dual-stack: IPv4 and IPv6 are independent and\nboth surface through the same DNAT/MASQUERADE plane.\n\nIt exists because we wanted \"one server, one service-pack\" semantics back —\nthe way it used to be when Mailcow lived on its own physical box, Nextcloud\non another, and so on — but with the operational ergonomics of Compose.\n\n## Status\n\n**Production.** Running on a small self-hosted fleet since 2026-05.\nBoth modes implemented, full observability (metrics + health),\ncomprehensive test suite (unit + integration + e2e across all DHCP\nscenarios incl. stateful DHCPv6). The pre-v1 question — \"does this\nhold up on a real Linux box with a physical VLAN?\" — has been\nanswered by weeks of uptime under real workloads (SMTP, IMAP IDLE,\nLDAP binds, OIDC, video calls via Nextcloud Talk). The\nauto-generated report at the bottom is the release-readiness signal.\n\n*(Designed in a bathtub conversation. Has held up better than that\nhas any right to.)*\n\n### What v2.x adds on top of v2\n\nThe deltas since the v2 cut-over (each lives in its own\n`SPEC-*-DRAFT.md`; the codepath is gated behind one env var):\n\n- **F-39 / F-40 — Wrap pattern.** Service-anchors that join an\n  existing app container's netns via `network_mode: container:\u003cX\u003e`.\n  Lets you wrap a third-party Compose project (Mailcow, AIO) without\n  touching its compose file.\n- **F-41 — Default-route authority.** The network-anchor enforces\n  its default route on the macvlan (not on a Docker bridge), with\n  priority `DHCP Option 3 \u003e ANCHORD_EXT_GATEWAY_IP \u003e IPAM.Config.Gateway`.\n  Eliminates the asymmetric-reply-via-host-LAN failure mode that\n  killed long-lived TCP after ~17 min.\n- **F-42 — Label-selector discovery.** Backends matched by an\n  operator-supplied label set (`anchord.identity=ldap-outpost`)\n  instead of just the Compose project. Required for runtime-spawned\n  targets (Authentik outposts, K8s-shape operators).\n- **F-43 — Sibling auto-start.** Network-anchor watches docker\n  events and starts any `Created`-state sibling whose\n  `network_mode: container:\u003cX\u003e` resolves to a now-running target.\n- **F-44 — Co-attachment shared-network picker.** When anchord is\n  on multiple non-EXT networks, pick the one with the most observed\n  backends — settles deterministically, never flaps.\n- **F-45 — Managed service-anchors.** Network-anchor creates and\n  rebinds the service-anchor on demand when its target is spawned\n  (or recreated) outside of Compose. Auto-recovers when an\n  orchestrator recreates the target mid-flight (stale-netns detect).\n- **F-46 — Port-translating DNAT.** `anchord.expose=tcp/636:6636`\n  rewrites the destination port at DNAT time. Lets DMZ-side\n  reservation (LDAPS on 636) meet app-side reality (Authentik\n  outpost listening on non-privileged 6636).\n\n## The mental model\n\n\u003e **One Compose project = one classical server.**\n\nThat's the whole idea. Every service-anchor inside a project shares the same\nexternally-visible IPv4 and IPv6 — exactly as if `postfix`, `dovecot` and\nfriends were running side-by-side on a bare-metal host called `mailcow`. From\nthe outside there is no way to tell them apart; they're just ports on one\nmachine.\n\nConcretely:\n\n- **Inbound traffic** — clients connect to the project's single external IP.\n  anchord's DNAT map routes each port to the right service-anchor inside.\n  Postfix sees connections on 25/465/587, dovecot sees 143/993, both arriving\n  on what they perceive as their own interface, with the original client\n  source IP intact.\n- **Outbound traffic** — every container in the project egresses with the\n  same source IP, via masquerade. This matters for PTR records, SPF, IP\n  reputation, audit trails — anything where \"who was that?\" needs a single\n  consistent answer.\n- **Internal addresses** — yes, the service-anchors do have separate\n  Docker-bridge IPs on the transit network, because Docker has to route\n  packets between them somehow. But that's an implementation detail. From\n  the user's perspective, it doesn't exist.\n\n### What this implies\n\n**A given port can only point at one service-anchor.** Two containers that\nboth want to listen on 443 won't work — but that's also exactly what you'd\nhave on a real server. If you need multiple services on the same port (e.g.\nmultiple websites on 443), put a reverse proxy in front as a service-anchor\nand let *it* handle the layer-7 multiplexing. anchord stops at layer 4.\n\nThis is intentional: anchord doesn't try to be a reverse proxy, an ingress\ncontroller, or a service mesh. It gives you a server-shaped abstraction,\nand you build the rest with whatever tools fit.\n\n## How it compares\n\nanchord lives in a niche the usual tools don't quite fill — it's not\na reverse proxy, not an ingress controller, not a service mesh. It's\na layer-4 NAT shim that gives a Compose project a server-shaped\nnetwork identity. Quick map:\n\n| Approach | One IP per project? | Real source IPs preserved? | DHCP / hostname on the LAN? | Internal DNS service discovery? |\n|---|:---:|:---:|:---:|:---:|\n| `ports: \"1.2.3.4:80:80\"` | manual | no (bridge NAT mangles them) | no | yes |\n| `network_mode: host` | shared with host | yes | host's only | no per-stack |\n| `network_mode: macvlan` per service | no — one per container | yes | per container | broken (each container is its own L2 endpoint) |\n| Traefik / Caddy / nginx in host mode | no | yes for HTTP(S) only | no | yes |\n| Kubernetes ingress + LoadBalancer | yes (per Service) | depends on mode | not on bare LAN | yes |\n| **anchord** | **yes** | **yes** | **yes** | **yes** |\n\nIt's specifically built for *\"I want this Compose project to look like\na real server on my LAN\"* — the problem nothing else solves cleanly.\nanchord stops at layer 4 by design; if you need TLS termination,\nhostname routing, or HTTP-aware load balancing, run a reverse proxy\n*as* a service-anchor and let it own ports 80/443.\n\n## How it looks\n\nThe shared macvlan network is created once per host (out-of-band, or via\na tiny network-only compose project):\n\n```sh\ndocker network create -d macvlan \\\n    -o parent=eth0.42 \\\n    --subnet=192.168.150.0/24 --gateway=192.168.150.1 \\\n    dmz_macvlan\n```\n\nEach consumer project then references it as `external: true`:\n\n```yaml\nnetworks:\n  dmz:\n    external: true\n    name: dmz_macvlan\n  transit: { driver: bridge, internal: true }\n  backend: { driver: bridge, internal: true }\n\nservices:\n  anchord:\n    image: ghcr.io/alexcherrypi/anchord:latest\n    cap_add: [NET_ADMIN]\n    mac_address: \"02:4c:4b:50:0a:01\"   # stable across recreates\n    # anchord routes between dmz (macvlan) and transit (bridge), so\n    # the kernel needs forwarding on. accept_ra=2 keeps SLAAC working\n    # even when forwarding is enabled.\n    sysctls:\n      net.ipv4.ip_forward: \"1\"\n      net.ipv6.conf.all.forwarding: \"1\"\n      net.ipv6.conf.all.accept_ra: \"2\"\n    networks:\n      dmz: { ipv4_address: 192.168.150.100 }   # bootstrap IP\n      transit: {}\n    environment:\n      ANCHORD_PROJECT: ${COMPOSE_PROJECT_NAME}\n      ANCHORD_EXT_NETWORK: dmz_macvlan      # resolve iface via Docker API (MAC match)\n      ANCHORD_ADDRESS_MODE: dhcp-refresh    # or bootstrap, slaac-ra-only\n      ANCHORD_DHCP_HOSTNAME: mailcow\n      DOCKER_HOST: tcp://docker-proxy:2375\n\n  smtp-anchor:\n    image: ghcr.io/alexcherrypi/anchord:latest\n    cap_add: [NET_ADMIN]\n    environment:\n      ANCHORD_MODE: service-anchor\n    networks: [transit, backend]\n    labels:\n      anchord.expose: \"tcp/25,tcp/465,tcp/587\"\n\n  postfix:\n    image: postfix:latest\n    network_mode: \"service:smtp-anchor\"\n```\n\nThe full example with backend services lives in\n[compose.example.yaml](compose.example.yaml). Wrapping an existing\nCompose project (Mailcow, Nextcloud-AIO, …) without touching its\ncompose file: see [compose.example-wrap.yaml](compose.example-wrap.yaml)\nand the two-patterns section in [ARCHITECTURE.md](ARCHITECTURE.md).\n\nThat's it. anchord doesn't plumb the macvlan itself — Docker handles\nthat and hands anchord a regular interface. anchord watches the docker\nsocket, finds containers in the same compose project that carry the\n`anchord.expose` label, and wires up nftables DNAT entries pointing at\ntheir current bridge-network IPs. When containers restart and get new\nIPs, the maps update atomically and stale conntrack entries are flushed.\n\n### One image, two modes\n\nThe `anchord` image plays two roles in a project:\n\n- **Network-anchor** (`ANCHORD_MODE=network-anchor`, the default). One per\n  project. Joins the shared Docker macvlan network, optionally refreshes\n  its IP via DHCP (`ANCHORD_ADDRESS_MODE=dhcp-refresh`), and maintains\n  the nftables NAT state.\n- **Service-anchor** (`ANCHORD_MODE=service-anchor`). One per exposed service.\n  Resolves the network-anchor via Docker DNS, installs and maintains a default\n  route via it, and serves as the namespace owner that real application\n  containers join via `network_mode: service:\u003canchor\u003e`.\n\nBoth roles run the same binary; the mode is just an env var. As an alternative\nspelling, `command: [service-anchor]` does the same as setting `ANCHORD_MODE`.\n\n## Architecture\n\nFor the full picture — the three-role model (network-anchor,\nservice-anchors, backends), how traffic flows end-to-end, and the\ninvariants the code relies on — read [ARCHITECTURE.md](ARCHITECTURE.md).\nThe sketch below is the one-screen version.\n\nTwo companion docs round out the picture:\n[SPEC.md](SPEC.md) is the contract anchord must meet (functional\nrequirements, acceptance scenarios, non-goals), and\n[CONTEXT.md](CONTEXT.md) records the design rationale and the\nalternatives that were considered and rejected.\n\n```mermaid\nflowchart TD\n    %% Three roles in vertical layers: LAN -\u003e network-anchor -\u003e\n    %% transit-bridge -\u003e service-anchors (+ app containers joined\n    %% via netns share) -\u003e backend-bridge -\u003e DBs.\n    %% Edge styles: solid = traffic flow, thick = bridge membership,\n    %% dashed = netns share via network_mode service.\n\n    %% Shapes by role:\n    %%   [/.../]   = boundary  (the LAN, the DBs)\n    %%   {{ ... }} = bridge    (Docker L2 broadcast domain)\n    %%   [ ... ]   = container (anchord + service-anchors)\n    %%   ( ... )   = process   (app containers — share netns, no own IP)\n    LAN[/External LAN - VLAN eth0.42/]\n    DmzMv{{dmz_macvlan\u003cbr\u003eDocker macvlan, external: true}}\n    Anchord[anchord network-anchor mode\u003cbr\u003enftables DNAT-by-map\u003cbr\u003e+ masquerade + optional DHCP refresh]\n    Transit{{transit-bridge\u003cbr\u003eDocker bridge, internal: true}}\n    Smtp[smtp-anchor\u003cbr\u003eservice-anchor mode\u003cbr\u003enamespace owner]\n    Imap[imap-anchor\u003cbr\u003eservice-anchor mode\u003cbr\u003enamespace owner]\n    Postfix(postfix)\n    Dovecot(dovecot)\n    Backend{{backend-bridge\u003cbr\u003eDocker bridge, internal: true}}\n    DBs[/mysql, redis, .../]\n\n    LAN ==\u003e DmzMv\n    DmzMv ==\u003e|one IP per project, bootstrap or DHCP-refresh| Anchord\n    Anchord ==\u003e Transit\n    Transit ==\u003e Smtp\n    Transit ==\u003e Imap\n    Smtp -.-\u003e|network_mode service| Postfix\n    Imap -.-\u003e|network_mode service| Dovecot\n    Smtp ==\u003e Backend\n    Imap ==\u003e Backend\n    Backend ==\u003e DBs\n```\n\nThree layers, by design:\n\n1. **External** — a Docker macvlan network (`external: true`) shared by\n   every project that wants a LAN-visible IP. Docker plumbs the\n   host-side VLAN sub-interface and assigns each anchord container its\n   MAC and bootstrap IPv4 (declare `mac_address:` in compose for\n   stability; the DHCP client-id is derived from\n   `ANCHORD_DHCP_HOSTNAME` and is independent of the MAC, so\n   reservations stick across recreates).\n2. **Transit** — internal Docker bridge connecting anchord to the\n   service-anchors. `internal: true` ensures no Docker-managed MASQUERADE\n   meddles with our paths.\n3. **Backend** — internal Docker bridge for service-to-DB traffic. Most\n   containers live here, never see the transit network.\n\n### Why DNAT-by-map?\n\nnftables named maps let us express the entire DNAT table as a single rule\nthat consults a key/value lookup (the iface name is whatever Docker gave\nus — `eth0` by default, override via `ANCHORD_EXT_IFACE`):\n\n```\niifname \"eth0\" meta l4proto tcp dnat to tcp dport map @dnat_tcp\n```\n\nWhen a container restarts and its IP changes, we replace the map's contents\nin one atomic transaction. No rule deletions, no microsecond windows where\npackets fall through.\n\n### Why masquerade outbound, not SNAT?\n\nMasquerade automatically tracks the current source IP of the egress\ninterface — so when DHCP renews into a new lease, outbound traffic just\nkeeps working. SNAT to a literal IP would need re-pushing on every lease\nchange.\n\n### Why no `ports:` mapping anywhere?\n\nBecause `ports:` invokes Docker's userland proxy and bridge-NAT, which both\nmangle source IPs. anchord's whole point is to *not* go through that. Inbound\ntraffic enters the macvlan interface, hits anchord's DNAT in the kernel, and\narrives at the service-anchor with the original client IP intact.\n\n## Configuration\n\nAll via environment variables.\n\n### Common (both modes)\n\n| Variable                     | Required | Default            | Notes |\n|------------------------------|----------|--------------------|-------|\n| `ANCHORD_MODE`               | no       | `network-anchor`   | `network-anchor` or `service-anchor`. `command: [service-anchor]` is an equivalent override. |\n| `ANCHORD_LOG_LEVEL`          | no       | `info`             | `debug`/`info`/`warn`/`error` |\n| `ANCHORD_METRICS_ADDR`       | no       | `127.0.0.1:9090`   | Prometheus `/metrics` listen address. Loopback-only by default to avoid LAN exposure on the macvlan; set `:9090` to scrape from other compose services. `\"\"` disables. |\n\n### Network-anchor mode\n\n| Variable                     | Required | Default            | Notes |\n|------------------------------|----------|--------------------|-------|\n| `ANCHORD_PROJECT`            | yes¹     | `$COMPOSE_PROJECT_NAME` | Scope of containers anchord manages. Required unless `ANCHORD_LABEL_SELECTOR` is set. Ignored (with a WARN log) when both are set |\n| `ANCHORD_LABEL_SELECTOR`     | no       |                    | F-42: replaces the project filter with an operator-defined label set, comma-separated `key=value` AND-joined (e.g. `anchord.role=ldap-outpost,env=prod`). Use when multiple anchords share a project, or when target containers are spawned outside Compose and carry no project label (e.g. authentik outposts) |\n| `ANCHORD_AUTOSTART_SIBLINGS` | no       | `true`             | F-43: watch Docker for `container start` events and bootstrap any sibling container in `Created` state whose `network_mode: container:\u003cX\u003e` matches the just-started target. Needed for service-anchors whose target is spawned at runtime (e.g. authentik outposts via the Docker API). Requires `POST=1` on the docker-socket-proxy. Set to `false` to disable |\n| `ANCHORD_AUTOFIX_DEAD_NETNS` | no       | `true`             | Issue #10: when the network-anchor recreates its F-45-managed service-anchor (stale-netns or image-drift path), also re-create every wrap dependent that was pinned to the old SA's container ID. Without this, the dependents end up in a destroyed netns and look running to Docker while being invisible to the outside. Requires `DELETE=1` + container create/start on the docker-socket-proxy (same set F-45's existing SA recreate already needs). Set to `false` to keep v1.1.0 behaviour (detection-only via the dependents watcher's WARN log) |\n| `ANCHORD_MANAGED_SA_TARGET`  | no       |                    | F-45: stable name of a runtime-spawned target container. When set, the network-anchor not only auto-starts existing Created-state siblings (F-43) but CREATES a service-anchor on demand when this target appears. Needed when Compose cannot declare the service-anchor (target doesn't yet exist at compose-up time and Compose halts on create-then-cant-start). Empty = pure F-43 behaviour |\n| `ANCHORD_MANAGED_SA_NAME`    | no       | `\u003cTARGET\u003e-service-anchor` | F-45: name of the container the network-anchor creates. Only consulted when `ANCHORD_MANAGED_SA_TARGET` is set |\n| `ANCHORD_MANAGED_SA_IMAGE`   | no       | (anchord's own image) | F-45: image for the managed service-anchor. Default resolved at runtime from the network-anchor's own container inspect — keeps both containers on the same image version |\n| `ANCHORD_MANAGED_SA_GATEWAY_IP` | no    | (anchord's IP on shared network) | F-45: value passed as `ANCHORD_GATEWAY_IP` to the managed service-anchor. Default resolved at runtime from anchord's IP on whichever network the F-44 picker chose |\n| `ANCHORD_MANAGED_SA_EXTRA_ENV` | no    | `{}`               | F-45: additional env vars for the managed service-anchor, as a JSON object `{\"KEY\":\"value\",…}`. Operator overrides win against the standard `ANCHORD_*` defaults |\n| `ANCHORD_MANAGED_SA_LABELS`  | no       | `{}`               | F-45: labels to stamp on the managed service-anchor, as JSON `{\"key\":\"value\",…}`. Use case: inject `anchord.identity` / `anchord.expose` so an F-42 label-selector network-anchor can discover its own spawn. Reserved keys are rejected at load: `com.docker.compose.*` and `anchord.managed-by` |\n| `ANCHORD_ADDRESS_MODE`       | no       | `bootstrap`        | `bootstrap` (keep Docker-assigned IP), `dhcp-refresh` (DHCP-replace it), or `slaac-ra-only` (Docker-assigned v4, kernel SLAAC for v6) |\n| `ANCHORD_EXT_NETWORK`        | no       |                    | Docker network name of the external macvlan (e.g. `dmz_macvlan`). When set, anchord resolves its iface via the Docker API by MAC match. **Strongly recommended for any stack with 2+ networks** — `ANCHORD_EXT_IFACE=eth0` is a coin flip across recreates because Docker's eth0/eth1 assignment is non-deterministic |\n| `ANCHORD_EXT_GATEWAY_IP`     | no       | (auto-resolved)    | F-41: gateway IP for the default route enforced on the external iface. Comma-separated v4,v6 (same shape as service-anchor `ANCHORD_GATEWAY_IP`). Empty = read `IPAM.Config.Gateway` from `ANCHORD_EXT_NETWORK` via Docker NetworkInspect. In `dhcp-refresh` mode, DHCP Option 3 from the lease overrides both — wins forever once the first lease arrives. Pin when the macvlan is external without Docker-visible IPAM, or when you intentionally want a different gateway than DHCP/Docker would pick |\n| `ANCHORD_SHARED_NETWORK`     | no       |                    | F-44: pins the Docker network anchord uses to read backend IPs. When set, must be one of the networks anchord is attached to. Unset = heuristic mode (pick the candidate with the most backend co-attachment, ties via \"transit\" preference then alphabetical, re-evaluated until a backend is observed). Use when you have multiple transit-named bridges and the heuristic picks the wrong one |\n| `ANCHORD_EXT_IFACE`          | no       | `eth0`             | In-container name of the macvlan interface. Used only when `ANCHORD_EXT_NETWORK` is unset; if both are set, `ANCHORD_EXT_NETWORK` wins and a WARN is logged |\n| `ANCHORD_DHCP_HOSTNAME`      | no       | = project name     | Announced to the DHCP server in `dhcp-refresh`; also the basis of the DHCP client-id, so reservations are sticky across MAC changes |\n| `ANCHORD_POLL_INTERVAL`      | no       | `30s`              | Safety-net reconcile cadence |\n| `ANCHORD_DHCP_BACKOFF_MAX`   | no       | `5m`               | Max backoff between DHCP-client retries on protocol errors (only meaningful in `dhcp-refresh`) |\n| `DOCKER_HOST`                | no       | unix socket        | Set to `tcp://docker-proxy:2375` for socket-proxy mode |\n\nThe MAC is declared by the operator in compose (`mac_address:`),\nnot by anchord. If you don't pin one, Docker picks a deterministic\nMAC from the container name; either way the DHCP client-id is what\nkeeps reservations stable across recreates.\n\n### Service-anchor mode\n\n| Variable                            | Required | Default   | Notes |\n|-------------------------------------|----------|-----------|-------|\n| `ANCHORD_GATEWAY_HOSTNAME`          | no       | `anchord` | Compose-network DNS name to look up for the network-anchor's transit IP. Ignored when `ANCHORD_GATEWAY_IP` is set |\n| `ANCHORD_GATEWAY_IP`                | no       |           | Explicit gateway address(es), comma-separated v4 and/or v6 (e.g. `192.168.0.1,fd00::1`). When set, skips DNS resolution and routes directly to these addresses. Required for the wrap pattern (F-40) where the service-anchor runs inside a target container belonging to a different Compose project |\n| `ANCHORD_GATEWAY_RESOLVE_INTERVAL`  | no       | `5s`      | How often the service-anchor re-resolves and reconciles its default route (DNS mode only — has no effect when `ANCHORD_GATEWAY_IP` is set) |\n\n## Container labels\n\nOn any container that should be exposed via the project's external IP:\n\n| Label                | Example                       | Notes |\n|----------------------|-------------------------------|-------|\n| `anchord.expose`     | `\"tcp/25,tcp/465,udp/4500\"` or `\"tcp/636:6636,udp/53:5353\"` | Comma-separated entries. Each entry is `proto/port` (DNAT keeps the port) or `proto/dmz-port:backend-port` (F-46 port translation — DMZ-side reservation differs from app's listener). Backend-port omitted = same as DMZ-port |\n| `anchord.expose.v6`  | `auto` (default) / `off`      | Whether to mirror v4 rules onto AAAA |\n| `anchord.identity`   | `ldap-outpost`                | Free-form value matched by `ANCHORD_LABEL_SELECTOR` (F-42). Use when one Compose project hosts multiple anchord stacks, or when targets are spawned outside Compose and don't carry a project label |\n\n## Building\n\n```sh\ngit clone https://github.com/AlexCherrypi/anchord\ncd anchord\ngo mod tidy\ngo build ./cmd/anchord\ndocker build -t anchord:dev .\n```\n\n## Testing\n\nThe full test suite (Go unit tests + e2e harness across all four DHCP\nscenarios) is invoked via `scripts/update-test-report.sh`, which runs\nhost-independently inside a Docker container and rewrites the\nauto-generated **Test report** block at the bottom of this README on\ngreen. See [TESTING.md](TESTING.md) for the per-platform commands and\nthe release-gate contract.\n\n## Observability\n\nBoth modes serve `/metrics`, `/healthz` and `/readyz` on the same\nlistener (default `127.0.0.1:9090`, loopback-only so the LAN-facing\nmacvlan never sees it; set `ANCHORD_METRICS_ADDR=:9090` for\nproject-wide scraping or `\"\"` to disable). The surface is small and\ndeliberately bounded — see [SPEC §2.7](SPEC.md) for the full table —\nthe highlights operators usually want to alert on:\n\n- `anchord_dhcp_lease_remaining_seconds{family}` — alert when this\n  drops below your renewal window. Recomputed at scrape time.\n- `anchord_reconcile_total{result}` — error rate of the main loop.\n- `anchord_reconcile_duration_seconds` — verifies SPEC N-3 (≤ 500 ms p99).\n- `anchord_dnat_entries{family,proto}` — sanity gauge; spikes or drops\n  are a strong signal something is off.\n- `anchord_gateway_route_replaces_total{family}` (service-anchor) —\n  how often the network-anchor's transit IP changed under us.\n\nLabel cardinality is bounded by design (no per-container, per-IP, or\nper-port labels) — that would leak the project's internal structure\nacross the metrics surface, which contradicts the \"one project = one\nserver\" model.\n\n### Health endpoints\n\nSame listener, plain text:\n\n| Path | Code | When |\n|---|---|---|\n| `/healthz` | always `200 ok` | Process is up and serving HTTP. Pure liveness signal — does **not** flip on data-plane issues. |\n| `/readyz` (network-anchor) | `200 ready` | Once nftables tables are installed AND the first reconcile has completed. DHCP lease state is not part of readiness — the DNAT path works without one. |\n| `/readyz` (service-anchor) | `200 ready` | Once at least one default route (v4 or v6) has been installed. Pair with a Docker `HEALTHCHECK` so app containers joining via `network_mode: service:\u003canchor\u003e` wait for egress. |\n\nBoth `/readyz` variants return `503` with the unmet conditions in the\nbody while not ready.\n\n## Operator tooling\n\n### `anchord doctor stale-netns`\n\nOne-shot diagnostic for the wrap-pattern failure mode tracked in\n[issue #9](https://github.com/AlexCherrypi/anchord/issues/9): when a\nservice-anchor is recreated, every container declaring\n`network_mode: service:\u003cthat-anchor\u003e` stays pinned to the old\ncontainer ID and runs in a netns Docker has destroyed. The\ndependents look `running` to Docker but have no interface, no routes,\nno DNAT.\n\n```\n$ anchord doctor stale-netns\nFound 11 dependent(s) in dead netns across 9 target(s):\n\n  dead target: 158f0cc2  (1 victim(s))\n    ix-authentik-traefik-frigate-1\n      → docker compose -p ix-authentik up -d --no-deps --force-recreate traefik-frigate\n\n  dead target: a7c53426  (3 victim(s))\n    acme-init-xibo\n    acme-renewer-xibo\n    ix-xibo-traefik-1\n      → docker compose -p ix-xibo up -d --no-deps --force-recreate traefik\n  ...\n```\n\nScans the whole host, no compose project scope, no ANCHORD_*\nconfiguration needed — just docker.sock. Output is grouped by dead\ntarget so victims of the same gone service-anchor cluster, with the\nexact compose command to recover each.\n\nWhen anchord runs in network-anchor mode it also performs the same\ndetection in the background (scoped to its own compose project) and\nemits a structured `WARN dependent in dead netns ...` for every new\nvictim. The doctor command is for ad-hoc cluster-wide scans (anchord\nnot running, a different host, post-mortem analysis).\n\n## Caveats and known limitations\n\n- **Kernel ≥ 4.18** required for atomic nftables map replaces.\n- **CAP_NET_ADMIN** is required on every anchord container — the\n  network-anchor for macvlan + nftables, every service-anchor for\n  managing its own default route via netlink.\n- **The service-anchor's DNS name must match `ANCHORD_GATEWAY_HOSTNAME`.**\n  Default is `anchord`, which matches the canonical service name in the\n  example compose. If you rename the network-anchor service, set\n  `ANCHORD_GATEWAY_HOSTNAME` on each service-anchor to match.\n- **Recreating a service-anchor orphans its wrap dependents** —\n  but in the common case anchord now repairs them itself. Any\n  container declaring `network_mode: service:fe-anchor-X` is pinned\n  to fe-anchor-X's container ID at create-time and stays pinned\n  across recreate. When **anchord** recreates its F-45-managed\n  service-anchor (stale-netns or image-drift path), it enumerates\n  every wrap dependent of the old SA and re-creates each against\n  the new SA's ID before returning — see the `ANCHORD_AUTOFIX_DEAD_NETNS`\n  flag (default on). When **the operator** recreates a service-anchor\n  manually (`docker rm`, `compose up --force-recreate`), anchord's\n  v1.1.0 dependents watcher still emits a `WARN dependent in dead netns\n  ...` log line per victim with the exact recovery command — auto-fix\n  only fires for SA recreates anchord caused itself, because there\n  the scope is unambiguous (no race with operator, no scope\n  discovery). Use `anchord doctor stale-netns` for a cluster-wide\n  one-shot scan after a manual incident.\n- **One network-anchor per backend identity.** Default discovery\n  scope is the Compose project; two anchords filtering the same set\n  of backends will fight over their DNAT entries. With\n  `ANCHORD_LABEL_SELECTOR` (F-42), multiple anchord stacks coexist\n  cleanly in one Compose project as long as their selectors are\n  disjoint (each backend container is matched by exactly one\n  network-anchor). Each anchord container has its own netns, so\n  the per-process `anchord_v4` / `anchord_v6` nft tables don't\n  collide at the kernel level — but the macvlan IPs and DHCP\n  reservations still need to be operator-distinct.\n\n## License\n\nMIT — see [LICENSE](LICENSE).\n\n\u003c!-- TEST-REPORT-START --\u003e\n## Test report (auto-generated)\n\nThis block is rewritten by `scripts/update-test-report.sh` after a\ngreen run of the full test suite — every test below was observed to\nproduce the listed status on the source tree whose hash is recorded\nhere. The release pipeline rejects any tag whose recorded hash does\nnot match the current source, so this block is the project's\nrelease-readiness signal.\n\n- **Last verified:** 2026-05-23T13:44:45Z\n- **Code hash:** `sha256:86847f79079a156afd50c1eb7ffc422a112d3a16460627f7735635feae6a1525`\n- **Flood-fix flag:** `E2E_BRIDGE_FLOOD_FIX=1`\n\n### Summary\n\n| Suite | Pass | Fail | Skip | Total |\n|---|---:|---:|---:|---:|\n| `go vet ./...` | clean | — | — | — |\n| Go unit tests | 315 | 0 | 0 | 315 |\n| E2E (test/e2e, 5 scenarios) | 74 | 0 | — | 74 |\n| **All tests** | **389** | **0** | **0** | **389** |\n\n\u003cdetails\u003e\n\u003csummary\u003eGo unit tests \u0026mdash; 315/315 passed\u003c/summary\u003e\n\n| Package | Test | Status |\n|---|---|:---:|\n| `cmd/anchord` | `TestBuildDiscoveryDiscriminator/empty_selector_empty_project_→_nil_(config_layer_guards_this)` | ✓ |\n| `cmd/anchord` | `TestBuildDiscoveryDiscriminator/legacy_project_only` | ✓ |\n| `cmd/anchord` | `TestBuildDiscoveryDiscriminator/selector_AND-joined,_deterministic_order_by_key` | ✓ |\n| `cmd/anchord` | `TestBuildDiscoveryDiscriminator/selector_alone` | ✓ |\n| `cmd/anchord` | `TestBuildDiscoveryDiscriminator/selector_replaces_project_(both_set,_both_ignored_on_selector_path)` | ✓ |\n| `cmd/anchord` | `TestBuildDiscoveryDiscriminator_Deterministic` | ✓ |\n| `cmd/anchord` | `TestPrintStaleReport` | ✓ |\n| `cmd/anchord` | `TestRunDoctor_Dispatch/--help_is_not_an_error` | ✓ |\n| `cmd/anchord` | `TestRunDoctor_Dispatch/no_args_prints_usage` | ✓ |\n| `cmd/anchord` | `TestRunDoctor_Dispatch/unknown_subcommand_errors` | ✓ |\n| `cmd/anchord` | `TestSelectMode/ANCHORD_MODE=service-anchor` | ✓ |\n| `cmd/anchord` | `TestSelectMode/doctor_subcommand_recognised` | ✓ |\n| `cmd/anchord` | `TestSelectMode/explicit_network-anchor_subcommand` | ✓ |\n| `cmd/anchord` | `TestSelectMode/flag-only_args_are_ignored` | ✓ |\n| `cmd/anchord` | `TestSelectMode/no_args,_no_env_-\u003e_default_network-anchor` | ✓ |\n| `cmd/anchord` | `TestSelectMode/subcommand_wins_over_env` | ✓ |\n| `cmd/anchord` | `TestSelectMode/unknown_env_errors` | ✓ |\n| `cmd/anchord` | `TestSelectMode/unknown_subcommand_errors` | ✓ |\n| `internal/autostart` | `TestBackfill_F45_NoImageCheckWhenRecipePinsImage` | ✓ |\n| `internal/autostart` | `TestBackfill_F45_NoRebindOnAbsentSA` | ✓ |\n| `internal/autostart` | `TestBackfill_F45_NoRebindWhenAutoFixDisabled` | ✓ |\n| `internal/autostart` | `TestBackfill_F45_NoRecreateWhenImagesMatch` | ✓ |\n| `internal/autostart` | `TestBackfill_F45_ReboundDependentsOnImageDrift` | ✓ |\n| `internal/autostart` | `TestBackfill_F45_RecreatesSAOnImageDrift` | ✓ |\n| `internal/autostart` | `TestBackfill_NoStrandedSiblings_NoOp` | ✓ |\n| `internal/autostart` | `TestBackfill_StartsStrandedCreatedSibling` | ✓ |\n| `internal/autostart` | `TestFindOrphanCandidates_ByAllRefForms` | ✓ |\n| `internal/autostart` | `TestMatchSiblings_EmptyTargetReturnsNil` | ✓ |\n| `internal/autostart` | `TestMatchSiblings_IgnoresNonCreated` | ✓ |\n| `internal/autostart` | `TestMatchSiblings_IgnoresUnrelatedNetworkModes` | ✓ |\n| `internal/autostart` | `TestMatchSiblings_LeadingSlashTolerated` | ✓ |\n| `internal/autostart` | `TestMatchSiblings_LongIDMatch` | ✓ |\n| `internal/autostart` | `TestMatchSiblings_MultipleSiblingsAllFire` | ✓ |\n| `internal/autostart` | `TestMatchSiblings_NameMatch` | ✓ |\n| `internal/autostart` | `TestMatchSiblings_ShortIDMatch` | ✓ |\n| `internal/autostart` | `TestNew_NotNil` | ✓ |\n| `internal/autostart` | `TestReferencesFor_IncludesShortAndLongID` | ✓ |\n| `internal/autostart` | `TestRun_EventTriggersSiblingStart` | ✓ |\n| `internal/autostart` | `TestRun_F45_CreateErrorTolerated` | ✓ |\n| `internal/autostart` | `TestRun_F45_CreatesAndStartsWhenSAAbsent` | ✓ |\n| `internal/autostart` | `TestRun_F45_ExplicitGatewayIPWinsOverSelfIP` | ✓ |\n| `internal/autostart` | `TestRun_F45_ExtraEnvAndDeterministicOrder` | ✓ |\n| `internal/autostart` | `TestRun_F45_IgnoresDestroyOfUnrelatedContainer` | ✓ |\n| `internal/autostart` | `TestRun_F45_IgnoresUnrelatedTargets` | ✓ |\n| `internal/autostart` | `TestRun_F45_ImageDriftCheckSkippedOnEvent` | ✓ |\n| `internal/autostart` | `TestRun_F45_InactiveRecipeFallsBackToF43` | ✓ |\n| `internal/autostart` | `TestRun_F45_NoOpWhenManagedSAAlreadyRunning` | ✓ |\n| `internal/autostart` | `TestRun_F45_NoRecreateWhenSANetnsCurrent` | ✓ |\n| `internal/autostart` | `TestRun_F45_NoRespawnIfTargetAlsoGone` | ✓ |\n| `internal/autostart` | `TestRun_F45_NoSharedNetYetSkipsCreate` | ✓ |\n| `internal/autostart` | `TestRun_F45_OperatorLabelsReachSpec` | ✓ |\n| `internal/autostart` | `TestRun_F45_RebindContinuesAfterPerDepFailure` | ✓ |\n| `internal/autostart` | `TestRun_F45_ReboundDependentsOnStaleNetns` | ✓ |\n| `internal/autostart` | `TestRun_F45_RecreatesSAOnDestroy` | ✓ |\n| `internal/autostart` | `TestRun_F45_RecreatesSAOnStaleNetns` | ✓ |\n| `internal/autostart` | `TestRun_F45_SharedNetworkLookupIsLazy` | ✓ |\n| `internal/autostart` | `TestRun_F45_SkipsCreateWhenSAInCreatedState` | ✓ |\n| `internal/autostart` | `TestRun_IgnoresNonStartEvents` | ✓ |\n| `internal/autostart` | `TestRun_StartFailureIsLoggedButLoopContinues` | ✓ |\n| `internal/autostart` | `TestSATargetsStaleNetns/empty_netmode_tolerated` | ✓ |\n| `internal/autostart` | `TestSATargetsStaleNetns/non-container_netmode_is_not_our_concern` | ✓ |\n| `internal/autostart` | `TestSATargetsStaleNetns/ref_doesn't_resolve_at_all_(dead_netns)` | ✓ |\n| `internal/autostart` | `TestSATargetsStaleNetns/ref_is_a_12-char_short-ID_prefix_of_the_current_target` | ✓ |\n| `internal/autostart` | `TestSATargetsStaleNetns/ref_resolves_to_a_different_(still-listed)_container` | ✓ |\n| `internal/autostart` | `TestSATargetsStaleNetns/ref_resolves_to_current_target_by_full_ID` | ✓ |\n| `internal/autostart` | `TestSATargetsStaleNetns/ref_resolves_to_current_target_by_name` | ✓ |\n| `internal/autostart` | `TestTargetMatchesRecipe/#00` | ✓ |\n| `internal/autostart` | `TestTargetMatchesRecipe//ak-outpost-ldap` | ✓ |\n| `internal/autostart` | `TestTargetMatchesRecipe/abcdef012345` | ✓ |\n| `internal/autostart` | `TestTargetMatchesRecipe/abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789` | ✓ |\n| `internal/autostart` | `TestTargetMatchesRecipe/ak-outpost-ldap` | ✓ |\n| `internal/autostart` | `TestTargetMatchesRecipe/some-other-container` | ✓ |\n| `internal/config` | `TestFingerprintDeterministic` | ✓ |\n| `internal/config` | `TestFirstSelectorValue_Deterministic` | ✓ |\n| `internal/config` | `TestGetenvDefault` | ✓ |\n| `internal/config` | `TestLoadServiceAnchor_Defaults` | ✓ |\n| `internal/config` | `TestLoadServiceAnchor_GatewayIPDualStack/192.168.150.1,fd00::1` | ✓ |\n| `internal/config` | `TestLoadServiceAnchor_GatewayIPDualStack/fd00::1,_192.168.150.1` | ✓ |\n| `internal/config` | `TestLoadServiceAnchor_GatewayIPDuplicateFamily` | ✓ |\n| `internal/config` | `TestLoadServiceAnchor_GatewayIPEmpty` | ✓ |\n| `internal/config` | `TestLoadServiceAnchor_GatewayIPInvalid` | ✓ |\n| `internal/config` | `TestLoadServiceAnchor_GatewayIPSingle/v4` | ✓ |\n| `internal/config` | `TestLoadServiceAnchor_GatewayIPSingle/v4_with_whitespace` | ✓ |\n| `internal/config` | `TestLoadServiceAnchor_GatewayIPSingle/v6` | ✓ |\n| `internal/config` | `TestLoadServiceAnchor_Overrides` | ✓ |\n| `internal/config` | `TestLoadServiceAnchor_RejectsZeroInterval` | ✓ |\n| `internal/config` | `TestLoad_AddressModeInvalid` | ✓ |\n| `internal/config` | `TestLoad_AddressModeOverride/bootstrap` | ✓ |\n| `internal/config` | `TestLoad_AddressModeOverride/dhcp-refresh` | ✓ |\n| `internal/config` | `TestLoad_AddressModeOverride/slaac-ra-only` | ✓ |\n| `internal/config` | `TestLoad_AutoFixDeadNetns/FALSE` | ✓ |\n| `internal/config` | `TestLoad_AutoFixDeadNetns/TRUE` | ✓ |\n| `internal/config` | `TestLoad_AutoFixDeadNetns/explicit_false` | ✓ |\n| `internal/config` | `TestLoad_AutoFixDeadNetns/explicit_true` | ✓ |\n| `internal/config` | `TestLoad_AutoFixDeadNetns/garbage_rejected` | ✓ |\n| `internal/config` | `TestLoad_AutoFixDeadNetns/shorthand_0` | ✓ |\n| `internal/config` | `TestLoad_AutoFixDeadNetns/shorthand_1` | ✓ |\n| `internal/config` | `TestLoad_AutoFixDeadNetns/unset_→_default_true` | ✓ |\n| `internal/config` | `TestLoad_AutostartSiblings/FALSE` | ✓ |\n| `internal/config` | `TestLoad_AutostartSiblings/TRUE` | ✓ |\n| `internal/config` | `TestLoad_AutostartSiblings/empty-string_treated_as_default` | ✓ |\n| `internal/config` | `TestLoad_AutostartSiblings/explicit_false` | ✓ |\n| `internal/config` | `TestLoad_AutostartSiblings/explicit_true` | ✓ |\n| `internal/config` | `TestLoad_AutostartSiblings/garbage_rejected` | ✓ |\n| `internal/config` | `TestLoad_AutostartSiblings/shorthand_0` | ✓ |\n| `internal/config` | `TestLoad_AutostartSiblings/shorthand_1` | ✓ |\n| `internal/config` | `TestLoad_AutostartSiblings/unset_→_default_true` | ✓ |\n| `internal/config` | `TestLoad_ComposeProjectFallback` | ✓ |\n| `internal/config` | `TestLoad_DefaultsAndDerivations` | ✓ |\n| `internal/config` | `TestLoad_ExtIfaceOverride` | ✓ |\n| `internal/config` | `TestLoad_ExtNetworkOptional` | ✓ |\n| `internal/config` | `TestLoad_ExtNetworkSet` | ✓ |\n| `internal/config` | `TestLoad_HostnameOverride` | ✓ |\n| `internal/config` | `TestLoad_LabelSelectorAndProject_BothLoad` | ✓ |\n| `internal/config` | `TestLoad_LabelSelectorMalformed` | ✓ |\n| `internal/config` | `TestLoad_LabelSelectorReplacesProject` | ✓ |\n| `internal/config` | `TestLoad_LegacyProjectOnly` | ✓ |\n| `internal/config` | `TestLoad_ManagedSA_AllExplicit` | ✓ |\n| `internal/config` | `TestLoad_ManagedSA_DefaultsFromTarget` | ✓ |\n| `internal/config` | `TestLoad_ManagedSA_ExtraEnvEmpty` | ✓ |\n| `internal/config` | `TestLoad_ManagedSA_ExtraEnvMalformed` | ✓ |\n| `internal/config` | `TestLoad_ManagedSA_InactiveByDefault` | ✓ |\n| `internal/config` | `TestLoad_ManagedSA_LabelsMalformed` | ✓ |\n| `internal/config` | `TestLoad_ManagedSA_LabelsParsed` | ✓ |\n| `internal/config` | `TestLoad_ManagedSA_LabelsRejectsComposeKeys` | ✓ |\n| `internal/config` | `TestLoad_ManagedSA_LabelsRejectsManagedBy` | ✓ |\n| `internal/config` | `TestLoad_NoVLANParentRequired` | ✓ |\n| `internal/config` | `TestLoad_PollIntervalOverride` | ✓ |\n| `internal/config` | `TestLoad_ProjectOverridesCompose` | ✓ |\n| `internal/config` | `TestLoad_RequiresProject` | ✓ |\n| `internal/config` | `TestLoad_SharedNetworkEmptyByDefault` | ✓ |\n| `internal/config` | `TestLoad_SharedNetworkPin` | ✓ |\n| `internal/config` | `TestMetricsAddrFromEnv/explicit_empty_→_disabled` | ✓ |\n| `internal/config` | `TestMetricsAddrFromEnv/set_→_value` | ✓ |\n| `internal/config` | `TestMetricsAddrFromEnv/unset_→_loopback_default` | ✓ |\n| `internal/config` | `TestParseAddressMode/#00` | ✓ |\n| `internal/config` | `TestParseAddressMode/BOOTSTRAP` | ✓ |\n| `internal/config` | `TestParseAddressMode/bootstrap` | ✓ |\n| `internal/config` | `TestParseAddressMode/dhcp-refresh` | ✓ |\n| `internal/config` | `TestParseAddressMode/slaac-ra-only` | ✓ |\n| `internal/config` | `TestParseAddressMode/static` | ✓ |\n| `internal/config` | `TestParseBoolDefault/explicit_false_overrides_default_true` | ✓ |\n| `internal/config` | `TestParseBoolDefault/explicit_true_overrides_default_false` | ✓ |\n| `internal/config` | `TestParseBoolDefault/invalid_yields_error` | ✓ |\n| `internal/config` | `TestParseBoolDefault/unset_returns_default_false` | ✓ |\n| `internal/config` | `TestParseBoolDefault/unset_returns_default_true` | ✓ |\n| `internal/config` | `TestParseBoolDefault/whitespace-only_treated_as_unset` | ✓ |\n| `internal/config` | `TestParseDuration/duration_string` | ✓ |\n| `internal/config` | `TestParseDuration/empty_uses_default` | ✓ |\n| `internal/config` | `TestParseDuration/invalid` | ✓ |\n| `internal/config` | `TestParseDuration/plain_int_=_seconds` | ✓ |\n| `internal/config` | `TestParseLabelSelector/F-42_example_—_Authentik_LDAP_outpost_role_selector` | ✓ |\n| `internal/config` | `TestParseLabelSelector/comma-joined_whitespace_tolerant` | ✓ |\n| `internal/config` | `TestParseLabelSelector/duplicate_key_with_conflicting_values_is_fatal` | ✓ |\n| `internal/config` | `TestParseLabelSelector/duplicate_key_with_same_value_collapses_(idempotent)` | ✓ |\n| `internal/config` | `TestParseLabelSelector/empty_input_yields_empty_map` | ✓ |\n| `internal/config` | `TestParseLabelSelector/empty_key_is_fatal` | ✓ |\n| `internal/config` | `TestParseLabelSelector/empty_value_is_valid_(matches_literal_empty)` | ✓ |\n| `internal/config` | `TestParseLabelSelector/entry_without_'='_is_fatal` | ✓ |\n| `internal/config` | `TestParseLabelSelector/single_pair` | ✓ |\n| `internal/config` | `TestParseLabelSelector/whitespace-only_input_yields_empty_map` | ✓ |\n| `internal/conntrack` | `TestFlushDestination_NilIPIsNoop` | ✓ |\n| `internal/conntrack` | `TestFlushDestination_NonzeroExitIsSilent` | ✓ |\n| `internal/conntrack` | `TestFlushDestination_V4Command` | ✓ |\n| `internal/conntrack` | `TestFlushDestination_V6Command` | ✓ |\n| `internal/dependents` | `TestFind_DeadRef` | ✓ |\n| `internal/dependents` | `TestFind_EmptyRefSkipped` | ✓ |\n| `internal/dependents` | `TestFind_LiveRefByLongID` | ✓ |\n| `internal/dependents` | `TestFind_LiveRefByName` | ✓ |\n| `internal/dependents` | `TestFind_LiveRefByShortID` | ✓ |\n| `internal/dependents` | `TestFind_ManyDependentsOnOneDeadTarget` | ✓ |\n| `internal/dependents` | `TestFind_NoComposeHintWhenLabelsMissing` | ✓ |\n| `internal/dependents` | `TestFind_NonContainerNetworkModesIgnored` | ✓ |\n| `internal/dependents` | `TestFind_RefToStoppedContainerIsLive` | ✓ |\n| `internal/dependents` | `TestFirstName` | ✓ |\n| `internal/dependents` | `TestRun_EmptyScopeIsNoOpAndExitsOnCtx` | ✓ |\n| `internal/dependents` | `TestTick_DoesNotReReportSameVictim` | ✓ |\n| `internal/dependents` | `TestTick_ListFailureToleratedNoVictims` | ✓ |\n| `internal/dependents` | `TestTick_ReReportsWhenVictimReturnsAfterFix` | ✓ |\n| `internal/dependents` | `TestTick_ReportsNewVictim` | ✓ |\n| `internal/dependents` | `TestTick_ScopeFiltersByComposeProject` | ✓ |\n| `internal/dhcp` | `TestClientID_PrefixesType` | ✓ |\n| `internal/dhcp` | `TestClientID_StableAcrossCalls` | ✓ |\n| `internal/dhcp` | `TestExtractV6Addrs_NoIANAYieldsNil` | ✓ |\n| `internal/dhcp` | `TestRenewalInterval_FallsBackToHalfLease` | ✓ |\n| `internal/dhcp` | `TestRenewalInterval_UsesT1` | ✓ |\n| `internal/dhcp` | `TestRun_PassiveModes/bootstrap` | ✓ |\n| `internal/dhcp` | `TestRun_PassiveModes/slaac-ra-only` | ✓ |\n| `internal/dhcp` | `TestRun_UnknownMode` | ✓ |\n| `internal/dhcp` | `TestSleepBackoff_CapsAtMax` | ✓ |\n| `internal/dhcp` | `TestSleepBackoff_DoublesBelowCap` | ✓ |\n| `internal/dhcp` | `TestSleepBackoff_RespectsContextCancel` | ✓ |\n| `internal/discovery` | `TestBackendEqual/V6_mode_differs` | ✓ |\n| `internal/discovery` | `TestBackendEqual/different_IPv4` | ✓ |\n| `internal/discovery` | `TestBackendEqual/different_IPv6` | ✓ |\n| `internal/discovery` | `TestBackendEqual/identical` | ✓ |\n| `internal/discovery` | `TestBackendEqual/rules_differ` | ✓ |\n| `internal/discovery` | `TestBackendEqual/rules_different_lengths` | ✓ |\n| `internal/discovery` | `TestBackendEqual/rules_order_swapped` | ✓ |\n| `internal/discovery` | `TestBuildEventFilter_NoExposeOnEvents` | ✓ |\n| `internal/discovery` | `TestBuildSnapshotFilter_EmptyDiscriminatorKeepsExposeGuard` | ✓ |\n| `internal/discovery` | `TestBuildSnapshotFilter_LabelSelectorAnd` | ✓ |\n| `internal/discovery` | `TestBuildSnapshotFilter_LegacyProject` | ✓ |\n| `internal/discovery` | `TestConsumeEventStream_CtxCancelStopsLoop` | ✓ |\n| `internal/discovery` | `TestConsumeEventStream_ErrSignalRequestsRetry` | ✓ |\n| `internal/discovery` | `TestConsumeEventStream_StaysOnSameStreamAcrossMessages` | ✓ |\n| `internal/discovery` | `TestParseIP` | ✓ |\n| `internal/discovery` | `TestPickIPs_NilNetworkSettings` | ✓ |\n| `internal/discovery` | `TestPickIPs_NoSharedFallsBackToFirst` | ✓ |\n| `internal/discovery` | `TestPickIPs_SharedNetworkAbsentReturnsNil` | ✓ |\n| `internal/discovery` | `TestPickIPs_SharedNetworkExplicit` | ✓ |\n| `internal/discovery` | `TestPickIPs_V4Only` | ✓ |\n| `internal/discovery` | `TestPickIPs_V6Only` | ✓ |\n| `internal/discovery` | `TestResolveSharedNetIPs_DirectAttachmentSkipsFollow` | ✓ |\n| `internal/discovery` | `TestResolveSharedNetIPs_FollowsContainerNetworkMode` | ✓ |\n| `internal/discovery` | `TestResolveSharedNetIPs_WrapTargetMissing` | ✓ |\n| `internal/discovery` | `TestRuleLess` | ✓ |\n| `internal/discovery` | `TestRunEventLoop_OnlyReopensAfterStreamEnds` | ✓ |\n| `internal/discovery` | `TestStateEqual` | ✓ |\n| `internal/discovery` | `TestTrimName` | ✓ |\n| `internal/extiface` | `TestResolve_APIError_RetriesThenFails` | ✓ |\n| `internal/extiface` | `TestResolve_ContextCancelStopsRetry` | ✓ |\n| `internal/extiface` | `TestResolve_EmptyMACTreatedAsNotYet` | ✓ |\n| `internal/extiface` | `TestResolve_EmptyNetworkName` | ✓ |\n| `internal/extiface` | `TestResolve_InvalidMACFormat` | ✓ |\n| `internal/extiface` | `TestResolve_MACMissingOnHost_Fatal` | ✓ |\n| `internal/extiface` | `TestResolve_NetworkAbsent_Fatal` | ✓ |\n| `internal/extiface` | `TestResolve_NetworkAttachedLate` | ✓ |\n| `internal/extiface` | `TestResolve_PicksByMACNotIfaceName` | ✓ |\n| `internal/extiface` | `TestResolve_Success` | ✓ |\n| `internal/extroute` | `TestRun_DHCPChannelClosedKeepsLastValue` | ✓ |\n| `internal/extroute` | `TestRun_DHCPDynamicOverridesPinAndIPAM` | ✓ |\n| `internal/extroute` | `TestRun_DHCPRenewalSameValueNoChurn` | ✓ |\n| `internal/extroute` | `TestRun_IPAMErrorFallsBackToPin` | ✓ |\n| `internal/extroute` | `TestRun_IPAMFallbackWhenNoPinNoDHCP` | ✓ |\n| `internal/extroute` | `TestRun_NothingResolved_QuietNoop` | ✓ |\n| `internal/extroute` | `TestRun_PinV4_IPAMv6_MixedSource` | ✓ |\n| `internal/extroute` | `TestRun_PinWinsOverIPAM` | ✓ |\n| `internal/extroute` | `TestRun_ReAssertsOnExternalRevert` | ✓ |\n| `internal/health` | `TestLiveness_AlwaysOK/fresh_tracker` | ✓ |\n| `internal/health` | `TestLiveness_AlwaysOK/tracker_with_state` | ✓ |\n| `internal/health` | `TestMarks_AreIdempotent` | ✓ |\n| `internal/health` | `TestNetworkAnchorReadiness_ReconcileAloneNotReady` | ✓ |\n| `internal/health` | `TestNetworkAnchorReadiness_StateMachine` | ✓ |\n| `internal/health` | `TestServiceAnchorReadiness_StateMachine` | ✓ |\n| `internal/labels` | `TestParse/F-46_backend_port_0_rejected` | ✓ |\n| `internal/labels` | `TestParse/F-46_backend_port_out_of_range` | ✓ |\n| `internal/labels` | `TestParse/F-46_mixed_list_—_one_translating,_one_not` | ✓ |\n| `internal/labels` | `TestParse/F-46_non-numeric_backend_port` | ✓ |\n| `internal/labels` | `TestParse/F-46_trailing_colon_(empty_backend_port)_is_fatal` | ✓ |\n| `internal/labels` | `TestParse/F-46_translation_—_Authentik_LDAPS_636_-\u003e_6636` | ✓ |\n| `internal/labels` | `TestParse/F-46_udp_translation_also_supported` | ✓ |\n| `internal/labels` | `TestParse/F-46_whitespace_around_translation_suffix_tolerated` | ✓ |\n| `internal/labels` | `TestParse/absent` | ✓ |\n| `internal/labels` | `TestParse/bad_port` | ✓ |\n| `internal/labels` | `TestParse/bad_proto` | ✓ |\n| `internal/labels` | `TestParse/empty_string_ignored` | ✓ |\n| `internal/labels` | `TestParse/missing_port` | ✓ |\n| `internal/labels` | `TestParse/mixed_protos_with_whitespace` | ✓ |\n| `internal/labels` | `TestParse/port_zero` | ✓ |\n| `internal/labels` | `TestParse/single_tcp` | ✓ |\n| `internal/labels` | `TestParse/v6_off` | ✓ |\n| `internal/metrics` | `TestLeaseRemaining_ClampsNegative` | ✓ |\n| `internal/metrics` | `TestLeaseRemaining_ClearDropsSeries` | ✓ |\n| `internal/metrics` | `TestLeaseRemaining_DecaysAtScrapeTime` | ✓ |\n| `internal/metrics` | `TestRegistryHasAllMetrics` | ✓ |\n| `internal/metrics` | `TestServe_BindFailureReturnsError` | ✓ |\n| `internal/metrics` | `TestServe_ServesMetrics` | ✓ |\n| `internal/nat` | `TestAddressFamily` | ✓ |\n| `internal/nat` | `TestFamilyString` | ✓ |\n| `internal/nat` | `TestIfaceBytes/empty` | ✓ |\n| `internal/nat` | `TestIfaceBytes/short_name_padded` | ✓ |\n| `internal/nat` | `TestIfaceBytes/typical_eth0` | ✓ |\n| `internal/nat` | `TestMapForFamProto` | ✓ |\n| `internal/reconciler` | `TestDesiredFromState_DualStack` | ✓ |\n| `internal/reconciler` | `TestDesiredFromState_Empty` | ✓ |\n| `internal/reconciler` | `TestDesiredFromState_F46PortTranslation` | ✓ |\n| `internal/reconciler` | `TestDesiredFromState_MultipleBackendsAndProtocols` | ✓ |\n| `internal/reconciler` | `TestDesiredFromState_SamePortFromTwoBackends` | ✓ |\n| `internal/reconciler` | `TestDesiredFromState_V4OnlyBackend` | ✓ |\n| `internal/reconciler` | `TestDesiredFromState_V6Off` | ✓ |\n| `internal/reconciler` | `TestDesiredFromState_V6OnlyBackend` | ✓ |\n| `internal/serviceanchor` | `TestDefaultRouteFor_Validation` | ✓ |\n| `internal/serviceanchor` | `TestIsAllZerosCIDR/0.0.0.0/0` | ✓ |\n| `internal/serviceanchor` | `TestIsAllZerosCIDR/10.0.0.0/8` | ✓ |\n| `internal/serviceanchor` | `TestIsAllZerosCIDR/::/0` | ✓ |\n| `internal/serviceanchor` | `TestIsAllZerosCIDR/fd30::/64` | ✓ |\n| `internal/serviceanchor` | `TestIsAllZerosCIDR/nil` | ✓ |\n| `internal/serviceanchor` | `TestReconcile_InstallsBothFamilies` | ✓ |\n| `internal/serviceanchor` | `TestReconcile_KeepsLastGoodOnLookupError` | ✓ |\n| `internal/serviceanchor` | `TestReconcile_NoOpWhenUnchanged` | ✓ |\n| `internal/serviceanchor` | `TestReconcile_ReplacesOnIPChange` | ✓ |\n| `internal/serviceanchor` | `TestReconcile_RetriesAfterFailedInstall` | ✓ |\n| `internal/serviceanchor` | `TestRun_GreenfieldMode_NoRestore` | ✓ |\n| `internal/serviceanchor` | `TestRun_IPMode_NoPeriodicResolve` | ✓ |\n| `internal/serviceanchor` | `TestRun_IPMode_SkipsDNS` | ✓ |\n| `internal/serviceanchor` | `TestRun_LoopsAndCleansUp` | ✓ |\n| `internal/serviceanchor` | `TestRun_RecordErrorTolerated` | ✓ |\n| `internal/serviceanchor` | `TestRun_WrapMode_DualStackRestore` | ✓ |\n| `internal/serviceanchor` | `TestRun_WrapMode_RestoresOriginalOnShutdown` | ✓ |\n| `internal/sharednet` | `TestCountBackendsPerNetwork` | ✓ |\n| `internal/sharednet` | `TestNew_CandidatesSortedAlpha` | ✓ |\n| `internal/sharednet` | `TestNew_PinnedNotInSelfNetworks_Rejected` | ✓ |\n| `internal/sharednet` | `TestPick_AllExcluded_ReturnsEmpty` | ✓ |\n| `internal/sharednet` | `TestPick_AuthentikFrigateBugFixed` | ✓ |\n| `internal/sharednet` | `TestPick_BackendCount_PicksHighest` | ✓ |\n| `internal/sharednet` | `TestPick_EmptyBackendSet_FallbackNoSettle` | ✓ |\n| `internal/sharednet` | `TestPick_FallbackThenSwitchOnFirstBackend` | ✓ |\n| `internal/sharednet` | `TestPick_PinnedOverride` | ✓ |\n| `internal/sharednet` | `TestPick_StableOnceSettled` | ✓ |\n| `internal/sharednet` | `TestPick_TieAllTransitAlphabetical` | ✓ |\n| `internal/sharednet` | `TestPick_TieTransitCaseInsensitive/FooTransitBar` | ✓ |\n| `internal/sharednet` | `TestPick_TieTransitCaseInsensitive/TRANSIT` | ✓ |\n| `internal/sharednet` | `TestPick_TieTransitCaseInsensitive/Transit` | ✓ |\n| `internal/sharednet` | `TestPick_TieTransitPreferred` | ✓ |\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003eE2E \u0026mdash; 74/74 passed across 5 scenarios\u003c/summary\u003e\n\n| Scenario | Assertion | Status |\n|---|---|:---:|\n| `v4-only` | anchord container running | ✓ |\n| `v4-only` | external iface attached on vlan subnet (resolved to eth1) | ✓ |\n| `v4-only` | anchord log confirms F-37 network-based iface resolution | ✓ |\n| `v4-only` | nftables anchord_v4 table installed | ✓ |\n| `v4-only` | nftables anchord_v6 table installed | ✓ |\n| `v4-only` | eth1 has IPv4 from 10.99.0.0/24 | ✓ |\n| `v4-only` | anchord_v4 dnat_tcp contains port 25 | ✓ |\n| `v4-only` | S-2 (v4) source IP preserved through DNAT | ✓ |\n| `v4-only` | S-2 (v6) source IP preserved through DNAT | ✓ |\n| `v4-only` | S-3 dnat_tcp:25 reflects current transit IP within 8s | ✓ |\n| `v4-only` | S-3 reachable on tcp/25 after recreate | ✓ |\n| `v4-only` | S-6 anchord exited cleanly (code 0) | ✓ |\n| `v4-only` | S-6 logs show graceful shutdown | ✓ |\n| `v4-only` | S-6 nat teardown clean (no warnings) | ✓ |\n| `v6-only` | anchord container running | ✓ |\n| `v6-only` | external iface attached on vlan subnet (resolved to eth1) | ✓ |\n| `v6-only` | anchord log confirms F-37 network-based iface resolution | ✓ |\n| `v6-only` | nftables anchord_v4 table installed | ✓ |\n| `v6-only` | nftables anchord_v6 table installed | ✓ |\n| `v6-only` | eth1 has IPv6 from fd99::/64 (RA or bootstrap) | ✓ |\n| `v6-only` | anchord_v6 dnat_tcp contains port 25 | ✓ |\n| `v6-only` | S-2 (v4) source IP preserved through DNAT | ✓ |\n| `v6-only` | S-2 (v6) source IP preserved through DNAT | ✓ |\n| `v6-only` | S-3 dnat_tcp:25 reflects current transit IP within 8s | ✓ |\n| `v6-only` | S-3 reachable on tcp/25 after recreate | ✓ |\n| `v6-only` | S-6 anchord exited cleanly (code 0) | ✓ |\n| `v6-only` | S-6 logs show graceful shutdown | ✓ |\n| `v6-only` | S-6 nat teardown clean (no warnings) | ✓ |\n| `both` | anchord container running | ✓ |\n| `both` | external iface attached on vlan subnet (resolved to eth0) | ✓ |\n| `both` | anchord log confirms F-37 network-based iface resolution | ✓ |\n| `both` | nftables anchord_v4 table installed | ✓ |\n| `both` | nftables anchord_v6 table installed | ✓ |\n| `both` | eth0 has IPv4 from 10.99.0.0/24 | ✓ |\n| `both` | eth0 has IPv6 from fd99::/64 (RA or bootstrap) | ✓ |\n| `both` | anchord_v4 dnat_tcp contains port 25 | ✓ |\n| `both` | anchord_v6 dnat_tcp contains port 25 | ✓ |\n| `both` | S-2 (v4) source IP preserved through DNAT | ✓ |\n| `both` | S-2 (v6) source IP preserved through DNAT | ✓ |\n| `both` | S-3 dnat_tcp:25 reflects current transit IP within 8s | ✓ |\n| `both` | S-3 reachable on tcp/25 after recreate | ✓ |\n| `both` | S-6 anchord exited cleanly (code 0) | ✓ |\n| `both` | S-6 logs show graceful shutdown | ✓ |\n| `both` | S-6 nat teardown clean (no warnings) | ✓ |\n| `none` | anchord container running | ✓ |\n| `none` | external iface attached on vlan subnet (resolved to eth0) | ✓ |\n| `none` | anchord log confirms F-37 network-based iface resolution | ✓ |\n| `none` | nftables anchord_v4 table installed | ✓ |\n| `none` | nftables anchord_v6 table installed | ✓ |\n| `none` | eth0 keeps Docker-bootstrapped IPv4 | ✓ |\n| `none` | eth0 keeps Docker-bootstrapped IPv6 | ✓ |\n| `none` | S-2 (v4) source IP preserved through DNAT | ✓ |\n| `none` | S-2 (v6) source IP preserved through DNAT | ✓ |\n| `none` | S-3 dnat_tcp:25 reflects current transit IP within 8s | ✓ |\n| `none` | S-3 reachable on tcp/25 after recreate | ✓ |\n| `none` | S-6 anchord exited cleanly (code 0) | ✓ |\n| `none` | S-6 logs show graceful shutdown | ✓ |\n| `none` | S-6 nat teardown clean (no warnings) | ✓ |\n| `dhcpv6-stateful` | anchord container running | ✓ |\n| `dhcpv6-stateful` | external iface attached on vlan subnet (resolved to eth1) | ✓ |\n| `dhcpv6-stateful` | anchord log confirms F-37 network-based iface resolution | ✓ |\n| `dhcpv6-stateful` | nftables anchord_v4 table installed | ✓ |\n| `dhcpv6-stateful` | nftables anchord_v6 table installed | ✓ |\n| `dhcpv6-stateful` | eth1 has IPv4 from 10.99.0.0/24 | ✓ |\n| `dhcpv6-stateful` | eth1 has IPv6 from fd99::/64 (DHCPv6 or bootstrap) | ✓ |\n| `dhcpv6-stateful` | anchord_v4 dnat_tcp contains port 25 | ✓ |\n| `dhcpv6-stateful` | anchord_v6 dnat_tcp contains port 25 | ✓ |\n| `dhcpv6-stateful` | S-2 (v4) source IP preserved through DNAT | ✓ |\n| `dhcpv6-stateful` | S-2 (v6) source IP preserved through DNAT | ✓ |\n| `dhcpv6-stateful` | S-3 dnat_tcp:25 reflects current transit IP within 8s | ✓ |\n| `dhcpv6-stateful` | S-3 reachable on tcp/25 after recreate | ✓ |\n| `dhcpv6-stateful` | S-6 anchord exited cleanly (code 0) | ✓ |\n| `dhcpv6-stateful` | S-6 logs show graceful shutdown | ✓ |\n| `dhcpv6-stateful` | S-6 nat teardown clean (no warnings) | ✓ |\n\n\u003c/details\u003e\n\u003c!-- TEST-REPORT-END --\u003e\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Falexcherrypi%2Fanchord","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Falexcherrypi%2Fanchord","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Falexcherrypi%2Fanchord/lists"}