Skip to content

Latest commit

 

History

History
209 lines (144 loc) · 13.5 KB

File metadata and controls

209 lines (144 loc) · 13.5 KB

Door

The gateway-door binary is a disposable redirector. It serves a cover page on / and issues an HTTP 302 on a small set of opaque slug paths to one of the currently healthy tenant mirrors. Doors hold no traffic: they answer the cover on arrival, redirect once, and never see the subsequent request. The mirror pool they point at is the same tenant registry the hub already keeps; doors consult the hub's mirror-health view when picking a target.

Doors are separate machines with their own public hostnames. They have no .onion reach, no admin surface of their own, and no knowledge of the backends behind each tenant. The door's only outbound calls are its mTLS heartbeat to the hub and the occasional fetch of an updated slug list; every user-facing request either returns the cover asset or emits a 302 to a public mirror hostname. Because doors have no direct line to the tenant backends, a compromised door reveals only the slug list and the set of public mirror hostnames it rotated between, both of which are already visible from outside.

Binary and config layout

The binary is cmd/gateway-door. It reads the same config.yaml schema as the other roles with node_type: door and uses the transport block, mTLS block, and hub URL fields exactly as a proxy does. Door-specific runtime is stored on the hub under $HUB_DATA_DIR/runtime/doors/<node_id>.yaml and streamed to the door over the same config-stream channel (GET /v1/config/stream?node_id=X). A door never writes its runtime config to disk; a restart resyncs from the hub.

Cover handler

The cover handler answers every GET / and every GET /cover.* path. Three kinds are supported.

static_file serves a file from disk. The operator stages an asset during install (often a cat picture, a TOS stub, or a landing page) and points the runtime config at its path. The door reads the file into memory at configure time and serves it with the configured content_type and any headers listed under headers:. The file is re-read on runtime-config change, never per request.

static_html serves an inline HTML string. Useful when the operator does not want to carry a separate asset file. The body is typed directly in the runtime config.

passthrough_404 returns an empty 404 with a Server: nginx header and no body. The response looks like the default page of an unconfigured nginx install. This is the lowest-profile option for a domain the operator does not want to draw attention to at all.

Runtime config for the cover block:

cover:
  enabled: true
  kind: static_file
  path: /etc/gateway/cover/landing.html
  content_type: "text/html; charset=utf-8"
  headers:
    Cache-Control: "public, max-age=3600"

Response shape for static_file:

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Cache-Control: public, max-age=3600
Content-Length: 2048

<!doctype html>...

Response shape for passthrough_404:

HTTP/1.1 404 Not Found
Server: nginx
Content-Length: 0

The cover handler strips every incoming header and does not echo query strings into the response. Two requests from different clients for the same cover kind produce byte-identical responses aside from the standard Date header.

Slug routing

Every slug is a 32-character random string generated by the hub when the slug is created. The door receives a list of active slugs in its runtime config. Request matching happens on the first path segment: /<slug> and /<slug>/<anything> both match. The trailing path after the slug is preserved and appended to the redirect target.

Match is performed with subtle.ConstantTimeCompare across every configured slug. The door iterates the full list every time rather than short-circuiting on the first match; a probing attacker cannot learn which slug matched from timing. When a request's first segment does not match any slug, the result is fed to the cover handler (the door never tells the caller "that looks like a slug but is wrong").

Methods handled:

  • GET and HEAD on a matching slug — 302 with Location pointing at the chosen mirror.
  • GET and HEAD on anything else — cover handler.
  • POST, PUT, DELETE, PATCH — 405 Method Not Allowed with empty body. Cover pages do not accept mutations, and redirecting a body-bearing request is neither safe nor desirable.

HEAD responses mirror what a GET would return, including the Location header on a slug hit and the cover's content-type on a cover hit, but carry no body. The goal is that HEAD probes produce responses that a plain static page would produce.

Redirects preserve the path tail and the query string:

GET /EXAMPLE-SLUG/releases/1.0?sig=abc HTTP/1.1
Host: door.your-domain.example

HTTP/1.1 302 Found
Location: https://mirror-3.your-domain.example/releases/1.0?sig=abc
Cache-Control: no-store

The door never sets cookies. It never sets Set-Cookie and it strips any cookie the upstream would have added (the door does not proxy, so this is academic; it is called out because the same handler is reused in local-mode testing).

Mirror selection strategies

Every slug has a strategy field that picks among the set of healthy mirrors returned by the hub. The filter stack is: start with all mirrors, intersect with target_tenants if the field is set, drop mirrors whose current verdict is not live, drop mirrors flagged blocked in any region listed under exclude_regions, then apply the strategy.

random picks uniformly at random from the filtered set. The random state is per door process. This is the default for a slug that points at a pool of equal-capacity mirrors.

- slug: "EXAMPLE-SLUG"
  strategy: random
  status: 302

weighted uses a weight field on each mirror (read from /v1/mirrors) to draw according to the weight distribution. A mirror with weight 2 is twice as likely to be chosen as a mirror with weight 1. Weight 0 excludes the mirror even if its verdict is live; this is the way to soft-disable a mirror without removing it.

- slug: "EXAMPLE-SLUG"
  strategy: weighted
  status: 302

round_robin rotates through the filtered set in a stable order. Each door process maintains its own counter. Two doors pointing at the same slug do not coordinate, so round-robin is approximately, not exactly, balanced across the door fleet.

- slug: "EXAMPLE-SLUG"
  strategy: round_robin
  status: 302

If the filtered set is empty (every mirror in the target set is blocked, degraded, or unknown), the door returns HTTP 503 with body {}. The operator's fronting CDN or monitoring notices the 503, not the user. The door does not reveal that selection failed; the same 503 is returned for any reason.

Admin operations

Slugs and mirror assignments are managed through the hub admin API. Doors never receive or accept admin writes directly; they only read their own runtime from the config stream.

Add a slug that rotates across every live mirror:

curl -sS \
  --cert /etc/gateway/admin.crt --key /etc/gateway/admin.key \
  --cacert /etc/gateway/hub-ca.crt \
  -X POST \
  -H 'Content-Type: application/json' \
  -d '{"slug":"EXAMPLE-SLUG","strategy":"random","status":302}' \
  https://10.0.0.1:9080/v1/doors/door-01/slugs

Rotate (replace) a slug without dropping the previous one immediately. The old slug stays live for grace_seconds (default 300):

curl -sS \
  --cert /etc/gateway/admin.crt --key /etc/gateway/admin.key \
  --cacert /etc/gateway/hub-ca.crt \
  -X POST \
  -d '{"old":"EXAMPLE-SLUG","grace_seconds":300}' \
  https://10.0.0.1:9080/v1/doors/door-01/slugs/rotate

Remove a slug:

curl -sS \
  --cert /etc/gateway/admin.crt --key /etc/gateway/admin.key \
  --cacert /etc/gateway/hub-ca.crt \
  -X DELETE \
  https://10.0.0.1:9080/v1/doors/door-01/slugs/EXAMPLE-SLUG

Changes propagate to the door through the existing config-stream channel. The door applies them atomically: a request arriving during the swap either sees the full old slug list or the full new one, never a half-updated state.

Runtime config examples for a door, as stored on the hub under $HUB_DATA_DIR/runtime/doors/<node_id>.yaml:

cover:
  enabled: true
  kind: static_file
  path: /etc/gateway/cover/landing.html
  content_type: "text/html; charset=utf-8"
slugs:
  - slug: "EXAMPLE-SLUG"
    strategy: random
    target_tenants: []
    status: 302
    exclude_regions: []
  - slug: "EXAMPLE-SLUG-B"
    strategy: round_robin
    target_tenants: ["example-a.your-domain.example"]
    status: 302
    exclude_regions: [RU]

Deployment notes

A door's public hostname should be distinct from any tenant mirror hostname. The door's cert, ACME account, and registrar record should not share identifiers with any mirror; the whole point of a door is to be an uncorrelated disposable entry.

DNS records for door hostnames should use a short TTL. A recommended value is 300 seconds (five minutes). Short TTL lets the operator point the domain at a different door (or remove it entirely) without clients continuing to hit the old address for hours. The cost is more frequent DNS lookups; this cost is small compared to the operational benefit.

The hub itself does not need a separate peer allocation for a door when transport is wireguard; the door's wg IP comes out of the same 10.0.0.0/24 pool and is listed alongside edge proxies. Firewall rules on the hub should accept door traffic only to the admin API port (9080) and explicitly deny door traffic to any other hub port. Doors should not be able to reach the SOCKS5 listener; mirror selection needs the admin API, not the Tor pool.

Runtime behavior and reload

The door's runtime state is four things: the current cover kind and asset, the slug list, the mirror registry snapshot, and the hub's check-host settings. All four come from the hub through the config stream. The door holds them as pointer-swapped immutable structs; a reload installs a new struct under a single atomic pointer update. Requests in flight read the old struct from their request context; requests arriving after the swap read the new struct. This is the same pattern the tenant registry uses and shares the same sync/atomic.Value primitive.

A door that loses the hub connection continues serving with its last known state. The cover handler works from the cached asset, and slug routing works from the cached mirror list. Latency on mirror health updates grows while disconnected, but no user request fails from a cache staleness alone. The door logs a single hub disconnected line at WARN level and the same line at reconnect; repeated reconnect attempts are not logged.

If a door fails to reach the hub for longer than stale_cutoff (default 30 minutes), it marks its cached mirror list as stale. A stale list still drives selection, but the door emits HTTP 503 for new slug requests once the cutoff is crossed; the cover handler continues to work. This is the only way the door degrades visibly; every other failure mode is silent.

Metrics

The door publishes a small set of Prometheus counters through the hub's existing metrics passthrough:

  • gateway_door_redirects_total{door, slug} — one increment per 302 returned.
  • gateway_door_cover_served_total{door, kind} — one increment per cover response.
  • gateway_door_method_not_allowed_total{door, method} — increments on 405.
  • gateway_door_no_candidates_total{door, slug} — increments when selection returned 503.
  • gateway_door_hub_disconnected_seconds{door} — gauge of current disconnect duration.

Slug labels are not hashed. The slug is not secret in the way a tenant hostname is (tenants are stable identities; slugs are rotated). Operators who want to hide slug labels in metrics can omit the metrics passthrough entirely for doors; the existing metrics.opsec.hash_tenant_labels flag governs tenant-label handling and is distinct from slug handling.

Known limitations

The door has no geo-aware routing in P2. exclude_regions filters based on the hub's health verdict, not based on the visitor's own region. The stub fields geo and region exist on mirror records for forward compatibility; the routing logic to use them is deferred.

The door has no per-visitor rate limiting in P2. The redirect itself is cheap and cacheable; an attacker can poll a known slug to enumerate the mirror set. Mitigations are external (put a CDN with rate limits in front of the door; run many parallel doors so per-door enumeration is incomplete). The existing rate_limit feature used on edges can be wired into the door in a later version; the decision to defer is so that P2 keeps the door surface minimal.

The cover handler does not support A/B rotation. Every request to / gets the same asset. If the operator wants multiple covers to look less suspicious under scan, they run multiple doors with different cover kinds behind different hostnames.

There is no access log on the door in P2 beyond hub-level heartbeat counters. The decision is deliberate: an access log on a door that sees probing traffic is a liability. Debug-level logs include the chosen mirror per redirect for troubleshooting; these are off in production. Operators who need a count of redirects per slug read the hub metrics counter gateway_door_redirects_total{door, slug}.

The door does not validate that the mirror hostname it redirects to is currently reachable from the visitor's network. Health data is the hub's view, filtered by check-host probes; it does not account for ISP-level or client-specific breakage. A client that cannot reach the redirect target sees a failure from their browser, not from the door. Operators who want a fallback path configure multiple slugs with overlapping mirror sets and publish both.