diff --git a/.claude/commands/deploy.md b/.claude/commands/deploy.md new file mode 100644 index 0000000..c7489cf --- /dev/null +++ b/.claude/commands/deploy.md @@ -0,0 +1,91 @@ +# Deploy to Production + +Pull latest `main` on the production server and rebuild/restart only the affected Docker services. Adapted from the Coves `/deploy` command — Tidepool runs on the same box, beside the Coves stack. + +## Production Target + +- Host: `ssh ` (resolve the placeholder from memory before executing — same server as Coves) +- Repo: `/opt/tidepool` +- Compose file: `docker-compose.prod.yml` +- Public domain: `tdpl.io` (TLS terminates in the **Coves** Caddy, `coves-prod-caddy`) + +## Service → Source Map + +Use this to decide what to rebuild based on the incoming commits. **Never rebuild a service whose code did not change.** + +| Service (container) | Built from | Triggered by changes in | +| ------------------------------------------ | -------------------------------- | ---------------------------------------------------------------------------------- | +| `tidepool` (`tidepool-prod`) | root `Dockerfile` → `tidepool:prod` | Go sources (`cmd/**`, `internal/**`), `go.mod`/`go.sum`, `lexicons/**`, `Dockerfile` | +| `tidepool-migrate` (`tidepool-prod-migrate`) | same image as `tidepool` | Never targeted directly — `up -d tidepool` runs it first via `depends_on` | +| `postgres` (`tidepool-prod-postgres`) | External image | Never rebuilt from this repo | +| — (`coves-prod-caddy`) | **Coves repo** | The `tdpl.io` site blocks live in the Coves `Caddyfile` — deploy those with the Coves `/deploy` (Step 5b there: `--force-recreate caddy` + inode check) | + +Special case — **`communities.yaml` only**: no rebuild at all. The follow list is read through a directory bind mount and the reconciler re-reads it every `FOLLOW_LIST_INTERVAL` (15m). `git pull` is the whole deploy; to apply immediately: + +```sh +ssh "curl -s -X POST localhost:8091/admin/communities/reconcile -H \"Authorization: Bearer \$(grep ^ADMIN_TOKEN /opt/tidepool/.env | cut -d= -f2)\"" +``` + +## Workflow + +### Step 1: Inspect remote state + +```sh +ssh "cd /opt/tidepool && git status && git fetch && git log HEAD..origin/main --oneline" +``` + +- Note any uncommitted local changes and untracked files — leave untracked files alone unless the user asks. +- List the incoming commits; these drive what needs a rebuild. + +### Step 2: Handle uncommitted local changes carefully + +If `git status` shows modified tracked files, **do not blindly discard them.** Verify with `git diff ` on the server against the incoming commits (`git show `). Identical-in-effect hotfix → safe to `git checkout --`. Anything else → **stop and ask the user.** + +### Step 3: Pull + +```sh +ssh "cd /opt/tidepool && git pull && git log --oneline -3" +``` + +Fast-forward only. If the pull fails for any reason other than the pre-handled local mods, stop and ask. + +### Step 4: Identify affected services + +Cross-reference the pulled commits against the Service → Source Map. If a commit touches `docker-compose.prod.yml` or `.env`-sensitive areas, stop and confirm — a recreate re-reads env and can break things silently. + +### Step 5: Rebuild + restart (Go changes) + +```sh +ssh "cd /opt/tidepool && docker compose -f docker-compose.prod.yml build tidepool" +ssh "cd /opt/tidepool && docker compose -f docker-compose.prod.yml up -d tidepool" +``` + +`up -d tidepool` re-runs `tidepool-migrate` first (shared image, `service_completed_successfully` gate) — the server only restarts on a successful migration. Never run bare `docker compose up -d` on prod. + +If migrations are in the incoming commits, watch the migrate job: + +```sh +ssh "docker logs tidepool-prod-migrate --tail 50" +``` + +### Step 6: Verify + +```sh +ssh "docker ps --format 'table {{.Names}}\t{{.Status}}'" +ssh "curl -sS -o /dev/null -w 'HTTP %{http_code}\n' https://tdpl.io/xrpc/_health" +ssh "docker logs tidepool-prod --tail 30" +``` + +- Wait for `tidepool-prod` to reach `healthy`. +- After a restart the bridge re-announces itself to `RELAY_HOSTS` (`requestCrawl`) — check the logs for the announcement succeeding. +- Spot-check the shipped feature (admin API via `localhost:8091`, or the public surface via `https://tdpl.io`). + +## Guardrails + +- **Never** run `docker compose down` on prod. Targeted `up -d ` only. +- **Never** `git reset --hard` / `git clean -fd` / delete untracked files on the server without explicit approval. +- `/opt/tidepool/.env` holds `BRIDGE_KEK` — the key sealing every bridged repo's signing keys. Never delete, rotate, or overwrite it casually; losing it bricks every bridged identity. +- The bridge's DIDs live on the public `plc.directory` — treat identity-affecting operations (KEK, `BRIDGE_HOSTNAME`) as irreversible. +- Changing `BRIDGE_HOSTNAME` breaks every minted handle and DID document — don't, without a migration plan. +- Caddy belongs to the Coves stack: `tdpl.io` routing/TLS changes are Coves-repo `Caddyfile` changes, deployed with the Coves `/deploy` (force-recreate + inode verification — single-file bind mount trap). +- Report back: commits pulled, services rebuilt, services intentionally NOT rebuilt, verification result. diff --git a/.env.prod.example b/.env.prod.example new file mode 100644 index 0000000..10c9c7a --- /dev/null +++ b/.env.prod.example @@ -0,0 +1,29 @@ +# Tidepool production environment — copy to .env on the server +# (/opt/tidepool/.env) and fill in real values. Compose reads it +# automatically. NEVER commit the filled-in file. + +# Postgres (container-internal; not exposed on the host) +POSTGRES_DB=tidepool +POSTGRES_USER=tidepool +# Generate with: openssl rand -hex 24 +POSTGRES_PASSWORD=CHANGE_ME + +# Public domain of the bridge. Handles are subdomains two levels below +# (alice.lemmy-world.tdpl.io), so DNS needs BOTH the apex A record and a +# wildcard: tdpl.io -> server IP, *.tdpl.io -> server IP. The wildcard MUST +# resolve directly to the server (Cloudflare: DNS-only / grey cloud) — +# Caddy issues per-handle certificates on demand via HTTP-01, which a +# proxying CDN edge breaks. +BRIDGE_HOSTNAME=tdpl.io + +# 32-byte key-encryption key sealing per-actor signing keys at rest +# (AES-256-GCM). Generate with: openssl rand -hex 32 +# Losing it means losing every bridged repo's signing keys — back it up. +BRIDGE_KEK=CHANGE_ME + +# Bearer token for the /admin API. Generate with: openssl rand -hex 32 +ADMIN_TOKEN=CHANGE_ME + +# Relays to announce the bridge to on startup (requestCrawl). bsky.network +# feeds the public Jetstream instances the Coves AppView consumes. +RELAY_HOSTS=https://bsky.network diff --git a/.gitignore b/.gitignore index 4e49dc8..ba736d6 100644 --- a/.gitignore +++ b/.gitignore @@ -14,5 +14,11 @@ # Go tooling coverage.out -# Local Claude Code session/tooling state -.claude/ +# Postgres dump target of docker-compose.prod.yml (server-side only) +/backups/ + +# Local Claude Code session/tooling state — but the deploy command is +# repo-committed (like the Coves one; it uses a placeholder so +# the SSH target stays out of git history) +.claude/* +!.claude/commands/ diff --git a/README.md b/README.md index f5f928a..49214cf 100644 --- a/README.md +++ b/README.md @@ -54,16 +54,22 @@ services: condition: service_completed_successfully ``` -The snippet above is a template to add to the operator's deployment Compose -file; this repository does not yet ship a standalone production Compose stack. -Once installed there, the normal deployment command rebuilds the shared local -image, runs the migration job, and starts Tidepool only if migration succeeded: +This repository ships that stack as +[docker-compose.prod.yml](docker-compose.prod.yml) (postgres + migrate + +server; TLS terminates in the operator's reverse proxy — see the file +header). With `.env` filled in from +[.env.prod.example](.env.prod.example), the normal deployment command +rebuilds the shared local image, runs the migration job, and starts +Tidepool only if migration succeeded: ```sh git pull -docker compose up -d --build +docker compose -f docker-compose.prod.yml up -d --build tidepool ``` +The repo-committed deploy runbook is +[.claude/commands/deploy.md](.claude/commands/deploy.md). + Requires Go 1.25+, Docker, and (for `make db-migrate` / `make lint`) the `goose` and `golangci-lint` CLIs. Store tests need a real postgres: they skip with a clear message when `TIDEPOOL_TEST_DATABASE_URL` is unset. diff --git a/cmd/tidepool/main.go b/cmd/tidepool/main.go index a090fae..b2fecf2 100644 --- a/cmd/tidepool/main.go +++ b/cmd/tidepool/main.go @@ -130,6 +130,10 @@ func run(logger *slog.Logger) error { resolver := identity.NewStoreResolver(actors, cfg.BridgeHostname, cfg.BridgeServiceDID) router.Get("/xrpc/com.atproto.identity.resolveHandle", identity.ResolveHandleHandler(resolver, logger)) router.Get("/.well-known/atproto-did", identity.WellKnownDIDHandler(resolver, logger)) + // Cert-issuance gate for TLS-terminating proxies with on-demand + // issuance (production Caddy asks here before requesting a cert for a + // bridged-handle subdomain; see docker-compose.prod.yml header). + router.Get("/.well-known/tidepool-tls-ask", identity.TLSAskHandler(resolver, logger)) // The sync surface (task 04): com.atproto.sync.* + subscribeRepos, // describeServer, _health — everything a relay or Jetstream needs to diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..fcee36c --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,125 @@ +# Tidepool production stack. +# +# Runs next to the Coves stack on the same box (/opt/tidepool beside +# /opt/coves). TLS terminates in the COVES Caddy (coves-prod-caddy), which +# fronts tdpl.io and every bridged-handle subdomain and reaches the bridge +# over the shared coves-prod-network — the `tidepool` service joins that +# network as an external one. The tdpl.io site blocks (including the +# on-demand-TLS cert gate pointing at /.well-known/tidepool-tls-ask) live in +# the Coves repo's Caddyfile. +# +# Migrations: production does NOT migrate on server start. The migrate +# one-shot and the server share one locally-built image; the server only +# starts after a successful migration run (service_completed_successfully), +# so the deploy command is a single targeted: +# +# docker compose -f docker-compose.prod.yml up -d --build tidepool +# +# Follow list: communities.yaml is read through a DIRECTORY bind mount +# (./:/repo:ro), not a single-file mount — docker pins single-file mounts to +# the inode present at container start, and `git pull` replaces the file, so +# a single-file mount would silently serve the pre-pull follow list forever +# (the same trap the Coves Caddyfile fell into). With the directory mount a +# plain `git pull` is enough; the reconciler picks the new file up on its +# next sweep (FOLLOW_LIST_INTERVAL, default 15m) — no container restart. +# +# Ops note (README "Sync surface"): the bridge's per-IP rate limiters key on +# RemoteAddr and see only Caddy's container IP from behind the proxy — +# per-IP limits degrade to global ones until rate limiting exists at the +# Caddy edge. Tracked in FOLLOWUPS.md; acceptable at current scale. + +x-tidepool-image: &tidepool-image + build: . + image: tidepool:prod + +services: + postgres: + image: postgres:16 + container_name: tidepool-prod-postgres + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-tidepool} + POSTGRES_USER: ${POSTGRES_USER:-tidepool} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - postgres-data:/var/lib/postgresql/data + - ./backups:/backups + networks: + - tidepool-internal + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tidepool} -d ${POSTGRES_DB:-tidepool}"] + interval: 10s + timeout: 5s + retries: 5 + deploy: + resources: + limits: + memory: 16G + reservations: + memory: 1G + + tidepool-migrate: + <<: *tidepool-image + container_name: tidepool-prod-migrate + command: ["migrate"] + restart: "no" + environment: + DATABASE_URL: postgres://${POSTGRES_USER:-tidepool}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-tidepool}?sslmode=disable + networks: + - tidepool-internal + depends_on: + postgres: + condition: service_healthy + + tidepool: + <<: *tidepool-image + container_name: tidepool-prod + restart: unless-stopped + ports: + - "127.0.0.1:8091:80" # host-local ops access (admin API without a round trip through Caddy) + environment: + ENVIRONMENT: production + DATABASE_URL: postgres://${POSTGRES_USER:-tidepool}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-tidepool}?sslmode=disable + LISTEN_ADDR: ":80" + BRIDGE_HOSTNAME: ${BRIDGE_HOSTNAME:-tdpl.io} + PLC_DIRECTORY_URL: https://plc.directory + BRIDGE_KEK: ${BRIDGE_KEK} + ADMIN_TOKEN: ${ADMIN_TOKEN} + # bsky.network fans out to the Jetstream instances the Coves AppView + # consumes (jetstream2.us-east.bsky.network) — this is the path + # bridged records take into Coves. + RELAY_HOSTS: ${RELAY_HOSTS:-https://bsky.network} + FOLLOW_LIST_PATH: /repo/communities.yaml + volumes: + - ./:/repo:ro + networks: + - tidepool-internal + - coves-prod-network + depends_on: + postgres: + condition: service_healthy + tidepool-migrate: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:80/xrpc/_health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + deploy: + resources: + limits: + memory: 8G + reservations: + memory: 512M + +networks: + tidepool-internal: + driver: bridge + name: tidepool-prod-internal + coves-prod-network: + external: true + +volumes: + postgres-data: + name: tidepool-prod-postgres-data diff --git a/internal/identity/handles.go b/internal/identity/handles.go index 65842fd..d66db17 100644 --- a/internal/identity/handles.go +++ b/internal/identity/handles.go @@ -130,6 +130,33 @@ func WellKnownDIDHandler(resolver Resolver, logger *slog.Logger) http.HandlerFun } } +// TLSAskHandler gates on-demand TLS certificate issuance for the bridged +// handle space: GET /.well-known/tidepool-tls-ask?domain= answers +// 200 iff the hostname is a handle the bridge would serve (resolvable, not +// tombstoned) and 404 otherwise. Reverse proxies terminating TLS with +// on-demand issuance (Caddy's `on_demand_tls { ask … }`) call this before +// requesting a certificate — without the gate, wildcard DNS makes every +// probe of . burn an ACME issuance attempt. +func TLSAskHandler(resolver Resolver, logger *slog.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + domain := strings.TrimSuffix(strings.ToLower(r.URL.Query().Get("domain")), ".") + if domain == "" { + http.Error(w, "missing required parameter: domain", http.StatusBadRequest) + return + } + _, err := resolver.ResolveHandle(r.Context(), domain) + switch { + case err == nil: + w.WriteHeader(http.StatusOK) + case errors.IsNotFound(err) || errors.IsValidation(err): + http.Error(w, "not a bridged handle", http.StatusNotFound) + default: + logger.Error("tls-ask lookup failed", "domain", domain, "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + } + } +} + func writeJSON(w http.ResponseWriter, status int, body any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) diff --git a/internal/identity/handles_test.go b/internal/identity/handles_test.go index d2ccdf3..de8b470 100644 --- a/internal/identity/handles_test.go +++ b/internal/identity/handles_test.go @@ -126,6 +126,46 @@ func TestWellKnownDIDHandler(t *testing.T) { }) } +func TestTLSAskHandler(t *testing.T) { + resolver := &fakeResolver{handles: map[string]string{ + "alice.lemmy-world.tidepool.example": "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + }} + handler := TLSAskHandler(resolver, testLogger()) + + get := func(target string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + handler(rec, httptest.NewRequest(http.MethodGet, target, nil)) + return rec + } + + t.Run("allows known handle", func(t *testing.T) { + assert.Equal(t, http.StatusOK, + get("/.well-known/tidepool-tls-ask?domain=alice.lemmy-world.tidepool.example").Code) + }) + + t.Run("normalizes case and trailing dot", func(t *testing.T) { + assert.Equal(t, http.StatusOK, + get("/.well-known/tidepool-tls-ask?domain=Alice.Lemmy-World.tidepool.example.").Code) + }) + + t.Run("refuses unknown domain", func(t *testing.T) { + assert.Equal(t, http.StatusNotFound, + get("/.well-known/tidepool-tls-ask?domain=random-probe.tidepool.example").Code) + }) + + t.Run("missing domain is 400", func(t *testing.T) { + assert.Equal(t, http.StatusBadRequest, get("/.well-known/tidepool-tls-ask").Code) + }) + + t.Run("resolver failure is 500, not an allow", func(t *testing.T) { + failing := TLSAskHandler(&fakeResolver{err: context.DeadlineExceeded}, testLogger()) + rec := httptest.NewRecorder() + failing(rec, httptest.NewRequest(http.MethodGet, + "/.well-known/tidepool-tls-ask?domain=alice.lemmy-world.tidepool.example", nil)) + assert.Equal(t, http.StatusInternalServerError, rec.Code) + }) +} + func TestStoreResolver(t *testing.T) { database := testutil.DB(t) testutil.Truncate(t, database, "bridged_actors")