# Gateway deployment runbook

The `astrid-gateway` is the HTTP front for the Astrid admin API. It
runs inside the `astrid-daemon` process, fronts the kernel's
`astrid.v1.admin.*` + `astrid.v1.request.*` IPC surfaces, and is the
contract dashboards (browser, web, CLI tooling) speak to.

This document is for operators standing the gateway up. If you're
looking for the *contract* (route shapes, schemas), point at
`GET /api/openapi.json` from a running daemon — it's the
authoritative spec.

## 1. Quickstart (single-tenant)

The smallest possible gateway:

```toml
# $ASTRID_HOME/etc/gateway-http.toml
enabled = true
listen = "127.0.0.1:2787"
```

Restart the daemon. The gateway is now serving plain HTTP on
loopback. Hit `/healthz` to confirm:

```sh
curl http://127.0.0.1:2787/healthz   # → ok
```

For single-box installs that don't need an HTTP front at all,
**delete** the config file (or set `enabled = false`) and the
gateway is a no-op — the rest of the daemon behaves exactly as it
did pre-v0.7.

## 2. Behind a reverse proxy (recommended)

Plain HTTP is fine on loopback but never on a public interface. The
recommended posture is "TLS upstream": the gateway listens on
loopback, nginx / Caddy / Cloudflare / your cloud LB terminates TLS
and forwards plain HTTP to the gateway.

### nginx

```nginx
server {
    listen 443 ssl http2;
    server_name astrid.example.com;

    ssl_certificate     /etc/letsencrypt/live/astrid.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/astrid.example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:2787;
        proxy_http_version 1.1;

        # SSE needs the response to stream as it arrives.
        proxy_buffering off;
        proxy_set_header Connection "";

        # Surface the real client IP so the redeem rate limiter
        # doesn't collapse every request onto the proxy's IP.
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}
```

### Caddy

```caddy
astrid.example.com {
    reverse_proxy 127.0.0.1:2787 {
        flush_interval -1            # SSE
        header_up X-Real-IP {remote}
    }
}
```

### Trust the forwarded headers

By default the gateway treats the immediate peer's IP as the client
IP. Behind a proxy that means *every* request appears to come from
the proxy and a single abusive client trips the redeem rate-limiter
for everyone. You **must** tell the gateway which proxies to trust:

```toml
trust-forwarded-from = ["127.0.0.1"]   # only loopback
# or, in a cloud LB scenario — list individual proxy IPs, CIDR
# blocks aren't parsed yet (`std::net::IpAddr` deserialisation
# only accepts bare addresses):
trust-forwarded-from = ["10.0.0.1", "10.0.0.2"]
```

The gateway only honours `X-Forwarded-For` / `X-Real-IP` when the
immediate peer's IP is in this list.

## 3. Native TLS (no reverse proxy)

For single-box installs that don't want to run a proxy, the gateway
can terminate TLS itself via rustls:

```toml
enabled = true
listen = "0.0.0.0:443"

[tls]
cert-path = "/etc/astrid/tls/cert.pem"
key-path  = "/etc/astrid/tls/key.pem"
```

### Cert workflow

1. **Bootstrap:** `certbot certonly --standalone -d astrid.example.com`.
2. **Symlink** into `/etc/astrid/tls/` (or point `cert-path` /
   `key-path` directly at the certbot output).
3. **Permissions:** `chmod 0600` on the key file. The gateway
   `WARN`-logs at boot if the key is group- or world-readable.
4. **Renewal:** today the gateway picks up new cert bytes only on
   daemon restart. Add a `--deploy-hook 'systemctl restart astrid'`
   to the certbot renew cron. (SIGHUP reload is tracked as a
   follow-up.)

### Boot-time validation

`GatewayConfig::validate` runs at daemon boot. It refuses to start
the gateway if:

- `cert-path` or `key-path` don't exist
- either points at a directory rather than a file
- both point at the same file

The error message tells you which knob; check `journalctl -u astrid`
or wherever you're collecting daemon logs.

### What you don't get yet

Tracked as follow-ups on the closed TLS issue:

- ACME / Let's Encrypt automation in-process.
- HTTP/2 / h2 ALPN (today negotiates HTTP/1.1 only).
- mTLS / client-cert auth.

## 4. Monitoring

The gateway exposes `/metrics` in Prometheus text-exposition format.
Unauthenticated by design — restrict access via firewall or your
reverse proxy.

### Counters

| Metric | Type | Labels | What it tells you |
|---|---|---|---|
| `astrid_gateway_requests_total` | counter | `method`, `route`, `status` | Request rate decomposed by status code. A 5xx spike here is the first thing to alert on. |
| `astrid_gateway_request_duration_seconds` | histogram | `method`, `route`, `status` | Per-route latency. Compute `histogram_quantile(0.99, …)` for p99. |
| `astrid_gateway_auth_failures_total` | counter | — | Bearer verification failures. A high rate alongside redeem attempts suggests credential-stuffing pressure. |
| `astrid_gateway_redeem_attempts_total` | counter | — | Total invite-redemption attempts. |
| `astrid_gateway_redeem_rate_limited_total` | counter | — | Redeem requests rejected by the rate limiter. If this is non-zero and you have legitimate dashboard traffic, your `trust-forwarded-from` is misconfigured. |

### Suggested alerts

```promql
# p99 admin latency > 500 ms over 5 min
histogram_quantile(0.99,
  sum by (route, le) (
    rate(astrid_gateway_request_duration_seconds_bucket{route=~"/api/sys/.*"}[5m])
  )) > 0.5

# any sustained 5xx rate
sum by (route) (rate(astrid_gateway_requests_total{status=~"5.."}[5m])) > 0.01

# auth failures > 10/sec
rate(astrid_gateway_auth_failures_total[1m]) > 10
```

### Per-request structured logs

Every request emits one `tracing` event with `method`, `route`
(matched template, not the raw URL), `status`, and `duration_ms`.
`/healthz` and `/metrics` log at DEBUG so liveness probes don't
drown the INFO stream. Pipe to your log aggregator of choice.

## 5. Authentication flow

```
operator                gateway                 kernel
   │ astrid invite issue  │                       │
   │ (CLI, on the box)    │                       │
   │─────────────────────────────────────────────▶│ persists hash to etc/invites.toml
   │ ◀──────────────────────────────────────────── │ returns raw token (one-shot)
   │
   │ hands raw token to a user out-of-band
   │
user
   │ POST /api/auth/redeem│                       │
   │ {token, public_key}  │                       │
   │─────────────────────▶│ verifies token,       │
   │                      │──────────────────────▶│ mints principal, persists key
   │                      │ mints bearer (signed) │
   │ ◀────────────────────│ {bearer, principal}   │
   │ stores bearer        │                       │
   │ for subsequent calls │                       │
```

### Bearer lifecycle

- Lifetime: 8 hours by default (`session-lifetime-secs` in config).
- Refresh: `POST /api/auth/refresh` extends without re-redeeming.
- Wire format v2: `b64url(principal).b64url(iat).b64url(exp).hex(sig)`.
- Signed with the gateway's ed25519 key at
  `$ASTRID_HOME/keys/gateway.ed25519`.

### Revocation

`DELETE /api/sys/principals/{id}` publishes an `AgentDelete`
audit event. The gateway subscribes to that feed and
records the deletion time in
`$ASTRID_HOME/etc/gateway-revocations.json`. Every bearer with
`iat <= revoked_at` is rejected on the next request. Backs up
to disk so revocations survive daemon restart.

**There is a sub-second window** between the audit event firing and
the revocation map being written. A request riding that window will
succeed. If you need immediate hard cut-off (e.g. compromised
admin), rotate the gateway signing key (see § 7).

## 6. CORS

`cors_allow_origins` controls which browser origins the gateway
accepts cross-origin requests from:

```toml
cors-allow-origins = [
    "https://app.example.com",
    "http://localhost:5173",   # dev
]
```

Rules (enforced at config-load time):

- Each entry must parse as `scheme://host[:port]` — no path, query,
  fragment, trailing slash, or userinfo.
- Scheme must be `http` or `https`.
- Internationalised domains must be supplied in their Punycode
  (ASCII) form (`xn--…`) because that's what browsers send in the
  `Origin:` header.

Empty allowlist = no `Access-Control-Allow-Origin` header on any
response = browsers reject cross-origin requests. That's the secure
default; only widen it when you have a real dashboard.

## 7. Key rotation

### Gateway signing key (`keys/gateway.ed25519`)

Used to sign session bearers. Rotating invalidates **all** active
sessions — every dashboard user is logged out and must re-redeem.
Procedure:

```sh
systemctl stop astrid
rm $ASTRID_HOME/keys/gateway.ed25519
systemctl start astrid
```

The daemon regenerates a fresh keypair on first boot. Same
posture as the kernel's `runtime.ed25519` runtime key.

### TLS cert/key

See § 3.4 "Renewal" — today, replace the files on disk and restart
the daemon.

## 8. Backup + restore

What to back up:

- `$ASTRID_HOME/etc/` — config, invite tokens, revocation map,
  any custom group definitions.
- `$ASTRID_HOME/keys/` — gateway signing key, kernel runtime key.
  Treat as secret material; `chmod 0700` the directory.
- Per-principal state under `$ASTRID_HOME/home/{principal}/` — capsule
  env, secret stores, KV — and per-principal audit chains under
  `$ASTRID_HOME/home/{principal}/.local/audit/`. The audit log isn't a
  monolithic `audit.db` file any more; each principal owns its own
  cryptographically-linked chain, so back up the whole principal
  home directory tree to retain auditable history. Operators
  legally required to retain that history must back these up;
  others can let them rotate per their own policy.

Restore order:

1. Stop the daemon.
2. Restore `etc/`, `keys/`, and the per-principal home directory
   trees (which carry the audit chains inside `.local/audit/`).
3. Start the daemon. Audit chains are verified on boot — if any
   chain doesn't match, `verify_all` logs an `error!` line but
   the daemon still comes up (fail-open for availability, loud
   alert for integrity).

The gateway loads its config + signing key at boot; nothing to
re-run.

## 9. Troubleshooting

### "gateway not configured" — no HTTP on the listen port

Check `$ASTRID_HOME/etc/gateway-http.toml` exists and `enabled = true`.
On missing-file the daemon silently skips spawning the gateway (this
is the single-tenant default). On `enabled = false` it logs a DEBUG
line saying so.

### Every request returns 401

The bearer either isn't being sent or doesn't verify.

```sh
curl -v -H "Authorization: Bearer $TOKEN" http://127.0.0.1:2787/api/auth/me
```

If the daemon log shows `Authorization` header missing, the proxy
is stripping it — re-check the reverse proxy's `proxy_set_header`
config. If the bearer is present but rejected, it's either expired
or signed by a different keypair (i.e. the gateway was restarted
and regenerated its key — see § 7).

### Rate limiter locks out every user

`astrid_gateway_redeem_rate_limited_total` climbing alongside
legitimate traffic almost always means `trust-forwarded-from` is
missing the proxy's IP — every redeem is being attributed to the
proxy and one client trips the limit for all. Add the proxy IP to
`trust-forwarded-from` and restart.

### CORS preflight fails for an origin that's in the allowlist

The allowlist is matched byte-for-byte against the browser's
`Origin:` header. Browsers strip trailing slashes and userinfo, send
IDNs as Punycode, and never include path/query. The boot-time
validator catches every common typo; if a request still fails,
`tcpdump`-capture the actual `Origin:` header and diff it against
your config.

### `/api/sys/audit` returns 502

The route requires the gateway to hold a live `Arc<AuditLog>` +
`SessionId`. The daemon plumbs both at boot; if `502` is returned
in production, the daemon binary was likely upgraded to a release
that ships the route but the daemon wasn't restarted to pick up the
new wiring. Restart and re-check.

## 10. Where to read more

- **API contract:** `GET /api/openapi.json` from a running daemon.
  Drop the URL into Swagger UI / Redoc / Scalar for a browsable
  surface; into `openapi-typescript` / `openapi-generator` /
  `kiota` for a typed client.
- **End-to-end test plan:** `scripts/e2e-stories.sh` walks the
  full admin loop from three perspectives (bootstrap admin, team
  operator, regular agent). Useful as a smoke test against any
  freshly-deployed instance — just point `GATEWAY=` at your URL.
- **Architecture:** the gateway is a Layer-2 "in-daemon library"
  rather than a kernel module or a capsule — see the
  in-daemon-services slot discussion under the v0.7 admin gateway
  PR (#768).
