ADR-0043 — Co-located connector tunnel fast-path over loopback¶
- Status: Proposed (Astra consult 2026-09-11: accept-with-changes, incorporated)
- Date: 2026-09-11
- Relates to: ADR-0019 (tailnet edge ownership), PR #484 (dispatcher
connection reuse),
.tickets/2026-09-11-observatory-502-dispatcher-connection-reuse.md,docs/ANVIL-CONNECT-TRANSPORT.md,connect/internal/tunnelgate/
Context¶
Anvil Connect's browser and API resources reach their origins through a
reverse tunnel. The connector dials the public tunnel URL
(wss://connect-tunnel.example.test), which resolves through
Cloudflare and terminates at cloudflared on the same host. The public
edge fronts the Go tunnel gate (connect/internal/tunnelgate/), which owns
admission: transport leases, exact reverse-descriptor validation, continuous
authority rechecks, gate-only certificate identity, and per-backend
credentials. The gate forwards to the private wstunnel backend on loopback.
Every connector tunnel connection therefore pays the public-edge loop:
Measured on the dashboard path (2026-09-11): a direct gate/envelope handshake costs ~20 ms while the same handshake through the CF loop costs ~150-330 ms serialized. PR #484 removed the per-request handshake cost for steady-state traffic (pooled h2, 2-connection budget), so warm request traffic multiplexes over one connection and no longer pays the loop. The residual cost is connection lifecycle: cold bursts, pool retirement (idle timeout, connector restart, leaf rotation), and reconnection each re-establish inner connections through the CF loop. The connector and gateway are co-located on primary-node; the segment between them does not need to leave the machine, but today the connector's tunnel dial does.
The same geometry applies to the API path (dashboard-api → router 8000)
and to any future co-located connector.
Considered options¶
- Accept the latency. Zero code. Warm traffic is healthy after PR #484; connection lifecycle stays on the ~150-330 ms-per-handshake loop.
- Move the browser/API edge onto the tailnet directly (Tailscale serve, per ADR-0019). Rejected: breaks public browser access for non-tailnet devices, moves the edge authority off the Connect gateway, and duplicates the auth model.
- Loopback fast-path through the existing gate — one managed path (chosen). Mount the existing tunnel gate on a dedicated local TLS entry point (its own trust root and entry identity), and let the deployment select that entry for co-located connectors. CF remains configured and is restored as a managed recovery action. No runtime session preference in this version.
- Dual-session automatic failover (connector holds both CF and local sessions; gateway prefers local per connection). Deferred: the current implementation has no path-selection seam (dispatcher dials one declared reverse address; tunnel clients start per resource), and simultaneous registration of two clients on the same reverse ports has no defined ownership. Adopting this later requires a pinned-wstunnel experiment covering simultaneous registration, connection assignment, disconnect, and stale-listener behavior, plus explicit restriction/address mapping — it must be a separate ADR, not silent growth.
- Unix-socket tunnel between gate and connector. Cleanest transport, but the pinned wstunnel and relay contract are TCP/WebSocket shaped. Deferred; supersede this ADR if adopted.
- Observatory asset bundling. Orthogonal, cache-friendliness work; does not address the connection-lifecycle RTT. Independent, measured separately.
Decision¶
Adopt option 3 — a single, explicitly selected local tunnel path through the existing admission gate:
- One entry, one gate. The gateway mounts the existing tunnel gate on a
dedicated loopback TLS entry point under a dedicated trust root, with its
own entry leaf identity (
ServerName, HTTP Host, and path defined separately from the dial address). Certificate verification is never disabled for the IP address. The local entry exposes only the tunnel admission surface; it is not a general ingress. - Managed selection. A co-located connector's deployment may declare
the local entry as its active tunnel path.
anvil-connect-ctlpreview/apply owns schema validation (a literal, approved loopback address and port only — DNS-derived, redirect, userinfo, and proxied targets are rejected), listener lifecycle, trust material, startup ordering, and rollback to the CF-only selection. There is exactly one active path at runtime; switching paths is a managed apply, not an automatic runtime decision. - Authority is global. Both entries serve the same installation and
resource authority: same epoch, generation, key binding, and lease rules.
Revocation or lease expiry invalidates the local path exactly as it does
the public path. The 250 ms Active rechecks, per-request peer
verification, and PR #484's DER-based retirement apply unchanged.
Aggregate admission (
MaxConcurrent, Active) and connection budgets do not double — there is one gate and one set of limits. Loopback reachability is never authorization: loss of CF control-plane renewal still expires local access under the existing lease policy. The fast-path must not become an offline authorization extension. - Recovery contract. Runtime failure of the local entry fails requests
fast (no replay, no stream migration — recovery is a managed action that
re-selects CF and re-applies).
anvil-connect-ctlmust prove, in evidence: partial-apply failure, either listener dying, stale reverse listeners, gateway restart, connector restart, rotation, and restoration to CF-only operation. A failed optional leg must not terminate a healthy leg through shared supervision; repeated apply converges without unnecessary restarts; rollback restores the previous managed configuration without restoring revoked identity state. - Scope boundaries. Public browser ingress stays on Cloudflare plus Authelia. Remote connectors (ai-laptop-node, mid-mod) stay CF-only. Deployment-specific identities and endpoints stay in private operator configuration.
Expected effects¶
Baseline measurements (2026-09-11, primary-node): direct gate/envelope handshake ~20 ms; CF-loop handshake ~150-330 ms serialized. The fast-path targets bringing connection-lifecycle handshakes to the ~20 ms class — projected, not yet measured; acceptance must demonstrate the delta. Warm steady-state traffic is unchanged by this ADR (PR #484 already multiplexes it). Acceptance criteria: cold/warm burst latency, handshake counts, errors, retirement behavior, restart/rotation recovery, path failure, and POST execution counts on the selected path, plus revocation tests proving the local path dies with authority.
Consequences¶
- The gate gains a second mount surface (loopback TLS entry with dedicated trust root and identity) — the main new code surface. Review focus: entry identity, exposure (admission surface only), and no weakening of admission or revocation.
anvil-connect-ctlgains local-entry schema validation and lifecycle ownership with the evidence gates listed above.- Connector deployment gains an explicit active-path declaration; remote connectors are unchanged.
- Dual-session automatic failover (option 4) stays explicitly deferred behind a pinned-wstunnel experiment and a superseding ADR.
- Asset bundling (option 6) remains independent future work.
- Extending the fast-path beyond a literal loopback entry (option 5) supersedes this ADR rather than editing its decision.