From 376bafd0b7f116e8b7a6c694bedcef343ce0e265 Mon Sep 17 00:00:00 2001 From: jraedisch Date: Sun, 19 Jul 2026 17:23:08 +0200 Subject: [PATCH] gut to placeholder: code folded into the divepool monorepo (ADR-0042) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-serve onboarding (POST /api/v1/tokens + the OpenAPI surface) is retired; the MCP connector at pingarthur.com/mcp is the onboarding. The security disclosure channel (DM @pingarthur.com) stays. NOT PUSHED yet — push together with the divepool arc's deploy (see divepool docs/pingarthur-fold.md runbook). Co-Authored-By: Claude Fable 5 --- .env.example | 20 - .gitignore | 21 +- CLAUDE.md | 179 ----- Makefile | 33 - PLAN.md | 171 ----- Procfile | 23 - README.md | 87 +-- SECURITY.md | 6 +- admin.go | 143 ---- admin_test.go | 148 ---- ansible/README.md | 199 ------ ansible/ansible.cfg | 15 - ansible/bind_meta.yml | 261 ------- ansible/bootstrap.yml | 179 ----- ansible/canary.yml | 93 --- ansible/deploy.yml | 165 ----- ansible/etcd.yml | 145 ---- ansible/files/canary.service | 28 - ansible/files/etcd.service | 30 - ansible/files/nftables.conf | 41 -- ansible/files/pingarthur.service | 36 - ansible/files/sshd_hardening.conf | 7 - .../group_vars/all/meta_monitors.example.yml | 34 - ansible/group_vars/all/vars.yml | 36 - ansible/inventory.example.yml | 35 - ansible/templates/Caddyfile.j2 | 56 -- ansible/templates/canary.env.j2 | 17 - ansible/templates/etcd.conf.j2 | 24 - ansible/templates/pingarthur.env.j2 | 11 - ansible/tls.yml | 132 ---- ansible/vault.yml.example | 55 -- assets/og-image.jpg | Bin 34810 -> 0 bytes atmosphere.go | 565 --------------- atmosphere_test.go | 338 --------- checker.go | 387 ----------- checker_test.go | 650 ------------------ cmd/canary/main.go | 405 ----------- cmd/canary/main_test.go | 340 --------- dev_mode.go | 11 - dev_mode_dev.go | 17 - e2e_test.go | 146 ---- etcd.go | 48 -- etcd_test.go | 139 ---- go.mod | 89 --- go.sum | 313 --------- homepage.go | 78 --- homepage_test.go | 66 -- main.go | 330 --------- main_test.go | 99 --- monitors.go | 423 ------------ monitors_test.go | 616 ----------------- openapi.go | 75 -- openapi.json | 454 ------------ openapi_test.go | 134 ---- safehttp.go | 136 ---- safehttp_test.go | 129 ---- seo.go | 64 -- status.go | 75 -- status_test.go | 162 ----- supervision.go | 274 -------- supervision_test.go | 160 ----- token.go | 107 --- token_test.go | 139 ---- verify.go | 221 ------ verify_test.go | 264 ------- 65 files changed, 24 insertions(+), 9860 deletions(-) delete mode 100644 .env.example delete mode 100644 CLAUDE.md delete mode 100644 Makefile delete mode 100644 PLAN.md delete mode 100644 Procfile delete mode 100644 admin.go delete mode 100644 admin_test.go delete mode 100644 ansible/README.md delete mode 100644 ansible/ansible.cfg delete mode 100644 ansible/bind_meta.yml delete mode 100644 ansible/bootstrap.yml delete mode 100644 ansible/canary.yml delete mode 100644 ansible/deploy.yml delete mode 100644 ansible/etcd.yml delete mode 100644 ansible/files/canary.service delete mode 100644 ansible/files/etcd.service delete mode 100644 ansible/files/nftables.conf delete mode 100644 ansible/files/pingarthur.service delete mode 100644 ansible/files/sshd_hardening.conf delete mode 100644 ansible/group_vars/all/meta_monitors.example.yml delete mode 100644 ansible/group_vars/all/vars.yml delete mode 100644 ansible/inventory.example.yml delete mode 100644 ansible/templates/Caddyfile.j2 delete mode 100644 ansible/templates/canary.env.j2 delete mode 100644 ansible/templates/etcd.conf.j2 delete mode 100644 ansible/templates/pingarthur.env.j2 delete mode 100644 ansible/tls.yml delete mode 100644 ansible/vault.yml.example delete mode 100644 assets/og-image.jpg delete mode 100644 atmosphere.go delete mode 100644 atmosphere_test.go delete mode 100644 checker.go delete mode 100644 checker_test.go delete mode 100644 cmd/canary/main.go delete mode 100644 cmd/canary/main_test.go delete mode 100644 dev_mode.go delete mode 100644 dev_mode_dev.go delete mode 100644 e2e_test.go delete mode 100644 etcd.go delete mode 100644 etcd_test.go delete mode 100644 go.mod delete mode 100644 go.sum delete mode 100644 homepage.go delete mode 100644 homepage_test.go delete mode 100644 main.go delete mode 100644 main_test.go delete mode 100644 monitors.go delete mode 100644 monitors_test.go delete mode 100644 openapi.go delete mode 100644 openapi.json delete mode 100644 openapi_test.go delete mode 100644 safehttp.go delete mode 100644 safehttp_test.go delete mode 100644 seo.go delete mode 100644 status.go delete mode 100644 status_test.go delete mode 100644 supervision.go delete mode 100644 supervision_test.go delete mode 100644 token.go delete mode 100644 token_test.go delete mode 100644 verify.go delete mode 100644 verify_test.go diff --git a/.env.example b/.env.example deleted file mode 100644 index e5be648..0000000 --- a/.env.example +++ /dev/null @@ -1,20 +0,0 @@ -LISTEN_ADDR=:8080 - -# 32+ random bytes, hex or base64. Rotating invalidates all outstanding unbound tokens. -# Generate with: openssl rand -hex 32 -TOKEN_HMAC_SECRET= - -# Exactly 32 random bytes, hex or base64. Encrypts Atmosphere app passwords -# at rest (AES-256-GCM). Rotating/losing this renders every stored password -# unrecoverable — treat as once-forever. Generate with: openssl rand -hex 32 -ATMOSPHERE_ENC_KEY= - -# Comma-separated etcd client endpoints. In prod each node points at its local -# member over loopback; peer traffic (2380) runs on tailscale0 — see CLAUDE.md. -# The Procfile at repo root overrides this per-process so the 3 pa instances -# each talk to their own local etcd member (12379 / 22379 / 32379). -ETCD_ENDPOINTS=127.0.0.1:2379 - -# Cluster-wide cap on bound monitors. Phase 1 of the bind handshake rejects -# new binds with 429 once stats/monitor_count reaches this. Must be > 0. -MAX_MONITORS=10000 diff --git a/.gitignore b/.gitignore index 7897810..e36b1bd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,8 @@ -.env -.env.secrets -/pingarthur -*.log -BACKLOG.md -.etcd/ -.overmind.sock -ansible/vault.yml -ansible/inventory.yml -ansible/group_vars/all/meta_monitors.yml -ansible/group_vars/all/echo_tokens.yml -/bin/ -.claude/ +# Placeholder repo: only the pointer files are tracked. The pre-fold working +# copy remains on disk for local reference; everything lives in the divepool +# monorepo now (ADR-0042). +* +!README.md +!SECURITY.md +!LICENSE +!.gitignore diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 5b21ff3..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,179 +0,0 @@ -# Claude Instructions — PingArthur - -## What this is - -PingArthur is a minimal uptime monitoring service. Users prove URL ownership by echoing a bearer token in the `X-PingArthur-Token` response header on HEAD requests, bind a monitor by POSTing to `/api/v1/monitors` with that same bearer token, and poll `/api/v1/status` (same bearer) for the latest observation. - -Published open-source under `divepool.social` on tangled. Run as a public service at `pingarthur.com`, self-hostable by anyone. - -## Integration redesign (2026-07-17) — read before structural changes - -The integration design is DECIDED and lives in the divepool repo: **ADR-0042** -(`~/projects/divepool/docs/decisions/0042-pingarthur-fold-code-keep-plane.md`). Summary: this -codebase folds into the divepool monorepo (code + Ansible move; the 3-node Hetzner runtime -plane stays — that separation is a central point). MCP onboarding at `pingarthur.com/mcp` -replaces self-serve `POST /api/v1/tokens`; alert posting moves from app passwords to -PingArthur's own **confidential** atproto OAuth client (status-account sign-in, one flow); -monitors become DID-owned with an echo-death ≠ grant-death lifecycle. The implementation queue -is divepool `TODO.md`, Now item "PingArthur: keep + integrate" (steps a–g); this tangled repo -is gutted to a placeholder at step (e). Don't invest in the retiring paths (self-serve tokens, -app-password bind machinery) without checking the ADR first. - -## Scope (v0 — what's in) - -- Token issuance (stateless HMAC, 1h validity for the bind window) -- Monitor binding via `POST /api/v1/monitors` (bearer token + `{url}`); HEAD-probes the URL and records observation. Token-echo mismatch is accepted (monitor lands pending; 24h auto-stop grace). Hard HEAD failures (dns/tls/blocked/etc.) still reject. -- HEAD checks every 60min (default interval; per-monitor override via admin endpoint). First check is scheduled at a uniform random offset in `[0, interval)` so same-moment bind cohorts don't converge; subsequent checks lock to the fixed grid `scheduled + interval`. -- Status-change detection: any diff in (`http_status`, `X-Health-Status`, normalized error) tuple; latest observation plus a fixed-size ring buffer of the last N=10 changes are kept per monitor (inside the monitor blob — see Data model) -- `GET /api/v1/status` (bearer auth) → latest observation -- Auto-stop after 24h of missing/mismatched token header (monitor silently deleted, including any encrypted Atmosphere credential; `/api/v1/status` starts returning 404). **This is the deletion API** — there is no `DELETE` endpoint, intentionally: the bearer is a capability URL (non-secret), so URL-echo ownership is the only authority we honor for write-side actions. -- Idempotent re-bind (within the original token's 1h bind window, while a monitor exists for it): `POST /api/v1/monitors` re-runs verify and overwrites URL/schedule. `created_at`, the change-history ring buffer, and the Atmosphere binding are **preserved across re-bind**. Atmosphere is sticky after first bind — supplying an `atmosphere` block on a re-bind is rejected 400. To swap accounts: stop echoing → 24h auto-stop → fresh token + bind. - -## Non-goals (v0 — explicitly out) - -- User accounts -- Historical status retention beyond the N=10 change ring buffer (no pagination, no arbitrary history, no dedicated history endpoint) -- Uptime computations, SLA percentages, dashboards -- HTTP/GET fallback (HTTPS + HEAD only) -- Following redirects -- IPv6 -- Response body inspection -- Recording any response header other than `X-Health-Status` -- Email notifications, webhooks, Bluesky DMs (parked in `BACKLOG.md`). The **Atmosphere post channel** (users supply a dedicated ATProto account at bind time; we post `app.bsky.feed.post` on status-tuple changes) was added post-v0 — see `atmosphere.go`. - -## Sensitive-data posture - -**Assume all stored monitoring data is public.** The public `/api/v1/status` endpoint serves whatever we record, and anyone with the token (capability URL pattern) can read it. - -From each HEAD response we record **only**: -- `http_status` (int) -- `X-Health-Status` header value, truncated to 200 chars -- Normalized error category (`dns`, `tls`, `timeout`, `refused`, `blocked`, `token_mismatch`, etc.) - -Everything else — `Set-Cookie`, `Server`, `WWW-Authenticate`, any other header, the response body — is discarded before it touches memory we keep. The `X-PingArthur-Token` echo is verified, then the variable holding it is zeroed; never logged, never stored. - -When adding checker logic: never extend recorded fields without re-evaluating this posture. If a new field could carry PII/secrets (session IDs, internal paths, auth challenges, build hashes), don't record it. - -**Atmosphere credentials are the one exception** — when a monitor is bound with an `atmosphere` object, we persist the user's app password encrypted at rest (AES-256-GCM under `ATMOSPHERE_ENC_KEY`). It's the only stored-credential category in the system and the only field that must never appear in any response or log. The `/api/v1/status` handler returns `handle` + post-health metadata but never the encrypted ciphertext; users are directed to use a dedicated ATProto account, enforced at bind time by an empty-repo check on `app.bsky.feed.post`. - -## Architecture - -Three-node cluster. Each node runs the same Go binary plus a local etcd member. All state lives in etcd; nodes are otherwise stateless, symmetric, and interchangeable — **no leader election, no singleton work**. DNS round-robin (3 A records) distributes incoming traffic; losing any single node leaves the service live (etcd quorum holds with 2/3, and clients retry the next A record on connection-refused). - -Each node runs two long-lived goroutines: - -1. **HTTP server** — `POST /api/v1/tokens`, `POST /api/v1/monitors`, `GET /api/v1/status`, `GET /status` (health for supervisors). Runs on all 3 nodes. -2. **Checker** — runs on all 3 nodes in parallel. Range-scans the `due/` prefix in etcd, claims entries via CAS txn (`delete due/...` + `put claimed/` attached to a short lease), HEADs the URL via the SSRF-safe client, writes back on change, reschedules with a new `due/` key. **Each monitor is checked once per cycle**, from whichever node wins the claim — not once per node. - -No Postgres, no message queue, no Redis. etcd handles both storage and scheduling: the `due/` keyspace is ordered lexicographically by zero-padded unix timestamp, so "find due work" is a single range scan; atomic lease-backed claim replaces `FOR UPDATE SKIP LOCKED`. Three-node etcd gives us quorum-backed strong consistency, which is what makes "claim once across the cluster" correct. - -## Security — SSRF dialer - -All outbound HTTP (health checks, anything else) must go through a shared SSRF-safe `http.Client` whose `Transport.DialContext`: - -1. Resolves hostname to IPv4 (only). -2. Rejects any IP in blocked CIDRs: `127/8`, `10/8`, `172.16/12`, `192.168/16`, `169.254/16`, `100.64/10` (CGNAT/Tailscale), `0/8`, `224/4`, `240/4`. -3. Dials the **literal IP** (not hostname) to close the DNS-rebind gap. - -Timeouts: DNS 3s, TLS handshake 3s, total request 10s. `CheckRedirect` returns `ErrUseLastResponse` (never follow). - -## Supervision - -Divepool conventions apply: - -- **Errors that aren't explicitly handled must be fatal, not logged.** The point isn't logging discipline for its own sake — it's that "log and continue" hides bugs that then ship. `log.Fatalf` forces the issue to be either handled deliberately at the call site or surfaced as a crash that systemd restarts and an operator notices. Don't `log.Printf(err)` and keep going. -- Logs are short-term debugging only. Production observability is Prometheus + alerting, not log scraping. Every outbound channel (HEAD check) gets a `_total{result}` counter and a `_last_success_seconds` gauge; metrics + the `/status` health endpoint are the contract with operators. Plumb metrics in *before* shipping. -- `/status` (no-auth supervisor endpoint) returns 200 if the local etcd member is reachable, 503 otherwise. Repeated etcd failures cause `log.Fatalf` → systemd restart. -- The OpenAPI spec at `openapi.json` is the canonical reference for endpoint shapes, size limits, timeouts, and behavior. Update it whenever any of those change. The running service serves it at `GET /api/v1/openapi` (UA-sniffed: link-preview bots get OG HTML, everyone else JSON) plus the no-sniff `.json` and `.html` variants. - -## Data model (etcd keyspace) - -All state lives in etcd. No SQL, no separate tables; the keyspace is the schema. - -``` -monitor/ → JSON blob: {url, etld1, last_token_seen_at, - current: {http_status, health_status, - error, observed_at}, - history: [...last N=10 changes], - created_at} -due// → "" (secondary index; range-scan for due checks) -etld// → "" (per-domain membership marker) -etld_count/ → int-as-string; sibling of etld//* and the - thing the per-domain cap (≥10 ⇒ reject) is checked - against. Maintained in lockstep with etld//* - in the same txn — etcd Compare can't condition on a - prefix-range count, so the counter is the primitive. -bind_pending/ → "" (lease-backed, TTL ~30s; phase-1 reservation - marker between bind reservation and verify. Lease - expiry rolls back the reservation so a crashed - binder can't leak slots.) -claimed/ → "" (lease-backed, TTL ~60s; work-in-progress marker) -stats/monitor_count → int-as-string; incremented in bind txn, decremented - on auto-stop. Enforces global cap (MAX_MONITORS env, - default 10000) -``` - -No tokens keyspace (stateless HMAC). The monitor blob contains both the latest observation (`current`) and a fixed-size ring buffer of past changes (`history`, up to N=10; only grows when the observation tuple actually differs). Secondary indexes (`due/`, `etld/`) are maintained by hand in the same txn as the primary `monitor/` write — if you add a field that needs indexing, extend every write site. - -**Lookup efficiency**: checker finds due work via `Get("due/", WithPrefix(), WithLimit(N))` — etcd's ordered keyspace makes "oldest N due entries" an O(log n) seek + O(N) scan. Status lookups are point reads against `monitor/`. No extra indexes needed for current scope. - -## Coding guidelines - -- Program functionally; introduce new types only when necessary -- Stdlib first. Only add deps with clear justification -- When a dep is added or upgraded, default to `@latest`. Don't accept whatever `go mod tidy` resolves to — actively check. Pin an older version only when forced by upstream incompatibility, and call out the constraint plus a TODO to revisit -- Never commit secrets. `.env.example` is the configuration reference -- Errors that aren't deliberately handled at the call site must be fatal, not logged-and-continued. `log.Fatalf` forces the choice: either handle the error properly here, or surface it as a crash that systemd restarts and an operator notices. `log.Printf(err); return nil` is the anti-pattern — it ships bugs. Logs are short-term debugging only; production observability goes through Prometheus + `/status` (see Supervision) -- Don't comment what code does. Comment cross-references and runtime invariants not enforced by types -- Always build after changes. Always run `go vet ./...` - -## Testing - -- `testing` package, no assertion library -- Integration tests use embedded etcd (`go.etcd.io/etcd/server/v3/embed`) — in-process, no containers needed -- No mocking etcd. Run tests against a real server (embedded *is* the real server) -- Never skip tests. `t.Fatal` if dependencies are missing - -## Deployment - -Target: three Hetzner Cloud CAX11s (ARM Ampere, 2 vCPU, 4 GB, ~€3.79/mo each) running the latest Ubuntu LTS, one per zone — Falkenstein, Nuremberg, Helsinki — so any single-zone outage leaves quorum intact. Cross-compile: `GOOS=linux GOARCH=arm64 go build`. - -Each node runs three systemd units: -- **pingarthur** (app) as user `pingarthur`, talks to its local etcd via `127.0.0.1:2379` (plaintext; never leaves the box) -- **etcd** member, joined into a 3-node cluster; peer traffic (2380) listens on the Tailscale interface only -- **Caddy** terminating public TLS for `pingarthur.com` and reverse-proxying to `127.0.0.1:8080` - -Peer encryption: **Tailscale** provides the encrypted mesh between nodes; etcd itself runs plaintext on the `tailscale0` interface, and WireGuard handles the wire. Self-hosters who prefer no SaaS control-plane dependency can swap the Tailscale role for a WireGuard variant. - -Public routing: DNS round-robin (3 A records for `pingarthur.com`, TTL ~60s). No load balancer. Firewall default-denies ingress; only 22/80/443 on the public interface, 2379-2380 restricted to `tailscale0`. - -Provisioning via Ansible from initial passwordless root SSH (bootstrap → etcd cluster → app/TLS/DNS). - -## Open source / tangled - -- Repo: `tangled.org/divepool.social/pingarthur` -- Commits authored under the maintainer's personal identity, separate from the `divepool.social` namespace -- License: MIT -- `SECURITY.md` disclosure flow: DM `@pingarthur.com` on Bluesky -- No divepool-specific infra assumptions in code. Config-driven only - -## Key surfaces a fresh session should know about - -Breadcrumbs — each bullet points at the authoritative file. Read those -when the task touches that surface; don't duplicate their content here. - -- **Public API contract**: `openapi.json` (embedded into the binary, served at `GET /api/v1/openapi`/`.json`/`.html`). Enforced by `TestOpenAPI_Valid` + `TestOpenAPI_GeneratesGoClient` (an oapi-codegen Go client is generated in CI — if it stops compiling, the spec drifted) and `TestOpenAPI_RoutesMatchSpec` (asserts every `mux.Handle` route in `main.go` is in the spec, modulo operator-only). -- **Admin endpoint**: `PATCH /admin/v1/monitors` in `admin.go`. Loopback-only (not reverse-proxied by Caddy). Operator surface; deliberately absent from `openapi.json`. -- **Metrics**: `supervision.go` declares every `pa_*` metric. Scrape path is **a separate listener on `METRICS_ADDR` (default `:9090`)**, NOT on the main mux. nftables blocks this port publicly and accepts on `tailscale0`, so effective reach is tailnet peers only (Prometheus scraper). Event counters are per-node (sum across instances); etcd-sourced gauges are cluster-global (max/avg across instances). -- **Rolling deploys**: every ansible play that mutates running state (`deploy.yml`, `etcd.yml`, `tls.yml`) runs with `serial: 1`, so ≥ 2/3 nodes stay serving throughout. Add `serial: 1` to any new play that touches long-running services. -- **Local dev**: `Procfile` spins 3 etcd + 3 pingarthur. The app runs with `-tags dev` and `INSECURE_DEV_MODE=true` — see `dev_mode_dev.go` for what exactly that relaxes (bare-IP URLs, private-CIDR dialing, TLS verify). **Never ship `-tags dev` to prod**; `make build-linux-arm64` intentionally doesn't pass it. -- **ctx-guard convention** (shutdown safety): long-running goroutines (checker, watchdog) share the SIGTERM-cancelled ctx from `signal.NotifyContext`. Before any `log.Fatalf` on an etcd error in those goroutines, guard with `if ctx.Err() != nil { return }` so a clean shutdown doesn't masquerade as a real failure. Pattern is at 14 sites in `checker.go` + `supervision.go`. -- **Canary**: `cmd/canary/` is a deliberately-misbehaving HTTP target on a separate failure domain (divepool edge). 7-state cycle, one state per wall-clock minute. `PLAN.md` step 13 has the expected tuple per state + verification runbook. -- **Atmosphere post channel**: `atmosphere.go` holds everything — AES-256-GCM seal/open under `ATMOSPHERE_ENC_KEY`, ATProto client helpers (handle→DID, DID→PDS, createSession, listRecords, createPost), `prepareAtmosphere` (bind-time pipeline: resolve + empty-check + verify creds + encrypt), `doAtmospherePost` (fired from a detached goroutine whenever the checker commits a tuple change). App password encryption key is required at startup even if no monitor has a binding — fails loud rather than silently missing on first bind. -- **Build-time drift protection**: `go test ./...` + `go test -tags dev ./...` must both pass before deploy. The dev-tagged build additionally runs the full e2e pipeline test. - -## General reminders - -- Discuss before coding. Small, reviewable steps -- Only commit when explicitly asked -- Never `git commit --amend`. Never `git stash` diff --git a/Makefile b/Makefile deleted file mode 100644 index 29fa424..0000000 --- a/Makefile +++ /dev/null @@ -1,33 +0,0 @@ -.PHONY: build build-linux-arm64 test test-dev vet clean - -# Local dev build — runs with whatever's in .env (overmind path). -build: - go build -o bin/pingarthur . - -# Production deploy build. Ansible deploy.yml delegates to localhost and -# invokes this target, then rsyncs bin/pingarthur-arm64 to the Hetzners. -# No `-tags dev` here — the relaxed SSRF/TLS gates stay excluded from any -# binary that ever leaves this machine. -build-linux-arm64: - GOOS=linux GOARCH=arm64 go build -o bin/pingarthur-arm64 . - -# Canary ships to divepool edge (AX41 / x86). Ansible canary.yml invokes -# this target and rsyncs bin/canary-amd64 to /usr/local/bin/canary. -build-canary-linux-amd64: - GOOS=linux GOARCH=amd64 go build -o bin/canary-amd64 ./cmd/canary - -test: - go test -count=1 ./... - -# With the dev build tag + its TestEndToEnd_FullPipeline, which exercises -# real HTTP handlers against a real local TLS server (httptest). See -# dev_mode_dev.go for what the tag actually relaxes. -test-dev: - go test -count=1 -tags dev ./... - -vet: - go vet ./... - go vet -tags dev ./... - -clean: - rm -rf bin/ diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index 29615d0..0000000 --- a/PLAN.md +++ /dev/null @@ -1,171 +0,0 @@ -# PingArthur v0 — Implementation Plan - -As-built record of how v0 was shipped. Kept in-tree so the design rationale stays beside the code that implements it; each step left `main` buildable and independently reviewable as it landed. v0 is complete and live at [pingarthur.com](https://pingarthur.com); see [BACKLOG.md](BACKLOG.md) (local, gitignored) for what's parked beyond v0. - -See [CLAUDE.md](CLAUDE.md) for scope, non-goals, and conventions. - -## Steps - -### 1. HMAC token issuance -- [x] Stateless `pa_` token: 16 random bytes + issued-at unix + HMAC-SHA256 -- [x] `POST /api/v1/tokens` returns `{token, expires_at}` (1h validity — token-hash is the monitor identity once bound; `expires_at` governs the bind window only) -- [x] Unit tests: roundtrip, expiry, HMAC tamper detection - -**Done when**: `curl -X POST localhost:8080/api/v1/tokens` returns a decodable token. ✅ - -### 2. etcd client + keyspace layout -- [x] `go.etcd.io/etcd/client/v3` helper: multi-endpoint config, retry; client talks to local member over `127.0.0.1:2379` in prod -- [x] Keyspace layout (also documented in CLAUDE.md): - - `monitor/` → JSON blob (url, etld1, last_token_seen_at, created_at, `current` observation, `history` ring buffer of last N=10 changes) - - `due//` → secondary index for checker range scan - - `etld//` → per-domain cap index - - `claimed/` → lease-backed work claim (TTL ~60s) - - `stats/monitor_count` → global count, int-as-string; enforces `MAX_MONITORS` (env, default 10000) -- [x] Local-dev: single-node `etcd` binary; unit/integration tests use embedded etcd (`go.etcd.io/etcd/server/v3/embed`) - -**Done when**: binary connects to etcd and round-trips a CAS txn. ✅ - -### 3. SSRF-safe HTTP client -- [x] CIDR blocklist: `127/8`, `10/8`, `172.16/12`, `192.168/16`, `169.254/16`, `100.64/10`, `0/8`, `224/4`, `240/4` -- [x] Custom `DialContext`: resolve → validate all IPs → dial literal IP -- [x] IPv4 only, no redirects, timeouts (DNS 3s, TLS 3s, total 10s) -- [x] Unit tests: `127.0.0.1`, `10.x`, `100.64.x` all return `ssrf_blocked` - -**Done when**: `safehttp.Head("https://127.0.0.1/")` is rejected. ✅ - -### 4. URL verification -- [x] `verifyURL(url, token) -> (ok, observation, err)` -- [x] HEAD via SSRF client; confirm `X-PingArthur-Token` echoes token (constant-time compare) -- [x] Extract only: `http_status`, `X-Health-Status` (≤200 chars), normalized error category -- [x] Zero the token-echo variable after verification; never log/store -- [x] Extract eTLD+1 for per-domain cap (via `prepareURL`) - -**Done when**: integration test against a local HTTPS server passes. ✅ - -### 5. Monitor bind endpoint (two-phase reservation) -- [x] `POST /api/v1/monitors` with `Authorization: Bearer ` + JSON `{url}` -- [x] `prepareURL(url)` (already exists): reject 400 on non-https / bare IP / unparseable -- [x] HMAC-verify token; hash to `token_hash` -- [x] **Phase 1 — reserve a slot before any HEAD goes out.** Read `etld_count/` and `stats/monitor_count`, verify both under their caps, then a single CAS txn: - - Precondition (Compare-Value): `etld_count/` and `stats/monitor_count` *unchanged since the read* (CAS — etcd Compare can't condition on a prefix-range count, so we maintain explicit counters and use Value equality). If either changed: bounded retry (e.g., 3 attempts) before returning 429 - - On success: put `bind_pending/` attached to a short lease (TTL ~30s, > verify timeout) + put `etld//` + increment `etld_count/` + increment `stats/monitor_count` - - On cap exceeded (after retry): return 429 -- [x] **Phase 2 — verify ownership.** Call `verifyURL(url, token)`. If false, jump to phase 3 rollback. If the lease expires while we're verifying (process slow / GC pause), the verify result is no longer trusted — treat as failure -- [x] **Phase 3 — commit or rollback.** Single CAS txn (gated on `bind_pending/` still existing — i.e. lease hasn't expired): - - On verify success: delete `bind_pending/` (lease-attached) + put `monitor/` blob + put initial `due//` - - On verify failure: delete `bind_pending/` + delete `etld//` + decrement `etld_count/` + decrement `stats/monitor_count` + return 4xx with normalized error category -- [x] Crash safety: if the process dies between phase 1 and phase 3, the lease expires and the scavenger reconciles — `etld/`, `etld_count/`, and `stats/monitor_count` get rolled back when no `monitor/` or `bind_pending/` exists for that hash. (Same scavenger added in step 6.) -- [x] Idempotent re-bind of same `token_hash` already in `monitor/`: skip phase 1 entirely (slot already held), phase 2 verifies, phase 3 overwrites url + resets schedule. Counters untouched. -- [x] Request budget: every phase shares `r.Context()`, which the `http.Server` cancels at the 10s `WriteTimeout`. Body cap (4 KiB) bounds the URL ⇒ bounds the eTLD+1 ⇒ bounds every key we write. No per-phase deadline needed. -- [x] Response on success: 201 `{url, next_check_at}` - -**Why two-phase**: prevents a TOCTOU spam vector. Without reservation, 100 concurrent binds against the same eTLD+1 all see `count<10`, all fire HEADs at the target, only first 10 succeed at write — but we just sent 100 HEADs through our IPs. - -**Done when**: `curl -XPOST -H "Authorization: Bearer $TOKEN" -d '{"url":"..."}' .../api/v1/monitors` returns 201; `stats/monitor_count` and `etld_count/` reflect the new monitor; an 11th bind for the same eTLD+1 is rejected with 429 *without* a HEAD going out; a verify-failure path leaves no leaked `etld/`/`etld_count/` entries and no counter drift; oversize body returns 413 before phase 1. ✅ - -### 6. Checker loop (etcd work-claim) -- [x] Range-scan `due/` prefix with `Limit(N)` — all 3 nodes run checkers in parallel -- [x] Claim atomically via txn: `delete due//` + `put claimed/` attached to short lease -- [x] Call `checkURL` (post-bind hash-based variant of `verifyURL`: validates the echoed token's HMAC + matches its hash to the monitor's `tokenHash`, so the checker never needs the bearer) -- [x] If observation tuple differs from `current` in the blob: push previous `current` onto `history` (truncate to N=10, newest first), set new `current`, CAS-update -- [x] If token header absent/wrong: leave `last_token_seen_at` untouched; after 24h delete `monitor/`/`etld/`/`due/`/`claimed/` keys **and decrement `etld_count/` + `stats/monitor_count`** in the same txn (silent stop — user sees it via `/api/v1/status` returning 404) -- [x] Reschedule: delete `claimed/` + put new `due//` (60min ± 5min jitter) -- [x] Scavenger sweep: periodically re-insert `due/` entries for monitors with neither `due/` nor `claimed/` (recovers from workers whose lease expired mid-check) - -**Done when**: real URL status change is reflected in `/api/v1/status` within one check cycle; `history` grows on change and stays bounded at N=10; a multi-process local test shows work distributes (no monitor checked twice in one cycle). ✅ (Note: `/api/v1/status` itself is step 7; checker correctness is covered end-to-end via embedded-etcd tests asserting blob `current`/`history`, auto-stop teardown, and claim-race serialization.) - -### 7. Public status endpoint -- [x] `GET /api/v1/status` with `Authorization: Bearer ` -- [x] Hash token → lookup `monitor/` → return `current` + `history` from the blob -- [x] 404 if no bound monitor -- [x] JSON: `{url, current: {http_status, health_status, error, observed_at}, history: [...same shape, up to 10]}` - -**Done when**: `curl -H "Authorization: Bearer $TOKEN" .../api/v1/status` returns live data including history from any of the 3 nodes. ✅ - -### 8. Supervision -- [x] Prometheus metrics — plan's original set (`pa_check_total{result}`, `pa_check_duration_seconds`, `pa_check_last_success_seconds`, `pa_monitors_active`, `pa_status_change_total`, `pa_etcd_up`) plus an expanded set added post-v0: `pa_bind_total{result}`, `pa_monitor_autostop_total`, `pa_scavenger_recovered_total`, `pa_checker_scans_total`, `pa_check_queue_depth`, `pa_check_overdue_seconds` -- [x] Metrics scrape path: dedicated listener on `METRICS_ADDR` (default `:9090`), bound off the main mux so Caddy can't proxy it publicly. nftables blocks 9090 on the public NIC and accepts on `tailscale0`; reach is tailnet peers only -- [x] `/status` (separate from `/api/v1/status`) — no auth, returns 200 if etcd is reachable from this node, 503 otherwise; `log.Fatalf`s on repeated etcd failures (systemd restarts) -- [ ] Oh Dear monitor configured against `canary.pingarthur.com/sync-check` (subsumes plan's original "/status" target — sync-check tests the whole pipeline, not just liveness). *Manual, one-off.* - -**Done when**: killing the local etcd member makes that node's `/status` return 503; DNS round-robin routes clients to the other 2. ✅ Verified live 2026-04-20. - -### 9. Ansible bootstrap -- [x] Inventory: 3 Hetzner CAX11s, one each in Falkenstein / Nuremberg / Helsinki (boxes provisioned by hand; Ansible starts from "I have 3 IPs with passwordless root SSH") -- [x] `bootstrap.yml` (runs as `root` via initial passwordless SSH): - - Create `pingarthur` system user, install SSH pubkey, sudoers entry (passwordless for `systemctl` on pingarthur/etcd units only) - - Install Tailscale; `tailscale up` with ephemeral auth key from `ansible-vault`; enable `tailscaled` - - Install nftables; default-deny ingress on public interface; allow 22/80/443 tcp public; permissive on `tailscale0` (mesh is the trust boundary — etcd peer 2379-2380 rides it) - - Enable `unattended-upgrades` - - Harden sshd *last*: `PermitRootLogin no`, `PasswordAuthentication no`, restart — gated on a pre-flight "can we SSH as pingarthur" check so a broken run can't brick the box -- [x] Verified live 2026-04-20: bootstrap ran clean against all 3 CAX11s, root SSH rejected, pingarthur SSH + Tailscale mesh up. - -**Done when**: `ansible-playbook bootstrap.yml` succeeds; root SSH is rejected; `ssh pingarthur@` works; all 3 nodes reach each other on their Tailscale IPs. ✅ - -### 10. etcd cluster deploy -- [x] Install etcd (ARM64 binary) as systemd unit, `Restart=on-failure`, data dir `/var/lib/etcd` -- [x] `--listen-peer-urls` binds to the Tailscale IP only; `--listen-client-urls` binds to `127.0.0.1:2379` only. etcd itself runs plaintext — Tailscale (WireGuard) encrypts peer traffic at the network layer; client traffic never leaves the box -- [x] `--initial-cluster` = 3 peer URLs using Tailscale IPs (discovered from each node's `tailscale ip -4`); cluster token from `ansible-vault` -- [x] Verified live 2026-04-20: `etcdctl member list` on each node shows 3 healthy members; ~9-13 ms round-trips on peer ops. - -**Done when**: `etcdctl --endpoints=http://127.0.0.1:2379 member list` on every node shows 3 healthy members; `nmap` from outside confirms public 2379-2380 are closed. ✅ - -### 11. App deploy + TLS + DNS -- [x] Makefile: `GOOS=linux GOARCH=arm64 go build -o bin/pingarthur-arm64` -- [x] Ansible role (`deploy.yml`): rsync binary to `/usr/local/bin/pingarthur`; systemd unit runs as `pingarthur`; env file at `/etc/pingarthur/env` (`TOKEN_HMAC_SECRET`, `METRICS_ADDR`, etcd endpoints); `Restart=on-failure` with `StartLimitBurst=10` -- [x] Rolling rollouts: all mutating plays (`deploy.yml`, `etcd.yml`, `tls.yml`) run `serial: 1` so ≥2/3 nodes stay serving during updates -- [x] Caddy: TLS for `pingarthur.com`, reverse-proxy `/api/v1/*` + `/status` to `127.0.0.1:8080`. `/metrics` + `/admin/*` NOT exposed (operator-only surfaces) -- [x] TLS cert issuance: DNS-01 via `lego` + united-domains in `tls.yml` (multi-node auto-HTTPS races LE's secondary validation under DNS round-robin; static cert distribution avoids it) -- [x] DNS: 3 A records for `pingarthur.com` → the 3 node IPs (TTL ~60s), set at united-domains; plus wildcard `*.pingarthur.com` → same 3 IPs + more-specific `canary.pingarthur.com` → divepool edge -- [x] Verified live 2026-04-20: `curl https://pingarthur.com/status` returns 200 from each of the 3 IPs; TLS cert is production Let's Encrypt, identical fingerprint across nodes -- [x] Repo created on tangled under `divepool.social`, remote added, pushed. - -**Done when**: `curl https://pingarthur.com/status` returns 200 served by each node in turn; killing any single node does not take `/status` offline from the client's POV. ✅ - -### 12. Dogfood: monitor ourselves - -Subsumed by step 13's sync-check architecture. The canary's `/sync-check` endpoint queries pingarthur's `/api/v1/status` for the canary's own monitor and returns 200 iff pingarthur is correctly recording reality — a strictly stronger "is pingarthur working?" signal than polling `/status` for liveness. Pingarthur binds a second monitor against `/sync-check` itself, closing the self-verification loop. No separate self-monitor-of-`/status` needed. - -### 13. Canary self-test (deliberate status cycle + sync-check) - -A misbehaving-on-purpose HTTP target lets us verify pingarthur's whole pipeline at a glance: if history matches the schedule, every code path works. Smarter than pure /status monitoring — that only catches "process alive," not "checker actually checking." - -- [x] `cmd/canary/main.go`: **7-state cycle**, one state per wall-clock minute. States: `200+healthy → 200+degraded → 503 → missing-token-header → 12s-sleep → 500-byte-health-header → 32 KiB-junk-headers`. Exercises every error category pingarthur normalises (timeout, token_mismatch, other) plus the truncation + header-cap paths. -- [x] Per-monitor `interval_seconds` + one-shot bind-time jitter (lands monitors on deterministic grid). Exposed via operator-only `PATCH /admin/v1/monitors` (loopback-accessible from inside a node; not in public OpenAPI). -- [x] `ansible/canary.yml`: cross-compiles for linux/amd64, ships binary + systemd unit (DynamicUser) + env file to the `canary` inventory group (divepool edge — separate failure domain from the pingarthur cluster). -- [x] `GET /expected-state` — diagnostic JSON returning the observation tuple pingarthur *should* be recording right now. Useful for tooling. -- [x] `GET /sync-check` — compares pingarthur's `/api/v1/status` for the canary's monitor against the expected tuple for `observed_at`. Returns 200 "in sync" + echoes `SYNC_CHECK_ECHO_TOKEN` when healthy, 503 with a diff body otherwise. Self-verification loop closed: a second pingarthur monitor binds against `/sync-check` itself so `/api/v1/status` for the sync-check token answers "is pingarthur correctly observing reality?" -- [x] Deployed live to divepool edge. Two monitors, both on 60s cadence: - - `canary.pingarthur.com/` — the cycle; builds history of one tuple transition per minute - - `canary.pingarthur.com/sync-check` — the self-verdict; steady 200 when healthy, flips to 503 on drift - -**Done when**: -- `GET /api/v1/status` for the canary token shows the cycle's 7 tuple transitions recorded in order ✅ (live verification 2026-04-20) -- `GET /api/v1/status` for the sync-check token shows steady `(200, "", "")` observations on 60s cadence ✅ (live verification 2026-04-20) - -### 14. Open source + tangled push - -- [x] Create repo on tangled under `divepool.social/pingarthur`, add remote, `git push`. (See CLAUDE.md "Open source / tangled" section for conventions.) - -## Beyond v0 (landed after the original plan; noted for completeness) - -- **Admin endpoint** `PATCH /admin/v1/monitors` (loopback-only, bearer-auth'd) for per-monitor interval overrides — replaces the etcdctl surgery originally used to set canary cadence -- **Tier-1/2 metrics**: bind outcomes by result, autostops, scavenger recoveries, checker-scan heartbeat, queue depth, overdue age -- **Separate metrics listener** on `:9090` (tailscale0-only) so Prometheus can scrape across the mesh without ever exposing `/metrics` publicly -- **Rolling `serial: 1` deploys** on every mutating Ansible play -- **Server WriteTimeout 15s** (was 10s) so slow-target verifies produce a proper 4xx instead of a 502 EOF -- **Rune-safe X-Health-Status truncation** (byte-slice + strip trailing partial rune → always valid UTF-8) -- **Outbound response-header cap** (16 KiB, matches inbound `maxRequestHeaderBytes`) so a misbehaving target can't pressure us with megabyte header floods -- **Shutdown-safe ctx guards** on every etcd-facing `log.Fatalf` in checker + watchdog — SIGTERM no longer fakes a "context canceled" crash -- **OpenAPI drift-sync + signposts**: `openapi.json` matches served behaviour, `TestOpenAPI_*` enforces generatable client surface, `CLAUDE.md` has a "Key surfaces" section pointing new sessions at non-obvious files - -## Conventions checklist per step - -Before marking a step done: -- [ ] `go vet ./...` -- [ ] `go build ./...` -- [ ] Tests passing (`go test ./...`) -- [ ] No `log.Printf` on error paths — only `log.Fatalf` -- [ ] No recorded field carries PII/secrets (re-read "Sensitive-data posture" in CLAUDE.md) -- [ ] Cross-reference comments on code that depends on code elsewhere -- [ ] `openapi.json` reflects any new endpoints, response shapes, size limits, or timeouts diff --git a/Procfile b/Procfile deleted file mode 100644 index 387403f..0000000 --- a/Procfile +++ /dev/null @@ -1,23 +0,0 @@ -# Local 3-node etcd + 3 pingarthur instances. Mirrors the production topology -# enough to exercise claim-CAS across nodes and to rehearse the "kill one node, -# the other two keep serving" failure mode. -# -# First run: --initial-cluster-state=new bootstraps a fresh cluster. etcd writes -# cluster config into its data dir after that, so subsequent restarts ignore the -# bootstrap flags and just resume. To rebootstrap from scratch: rm -rf .etcd/ -# -# Overmind auto-loads .env from CWD — TOKEN_HMAC_SECRET must be set there -# (openssl rand -hex 32). Copy .env.example and fill it in. Per-process ENV -# on the Procfile lines below overrides .env so each pa instance points at a -# different local etcd member. -# -# Needs: brew install etcd overmind - -etcd1: etcd --name=pa1 --data-dir=.etcd/pa1 --listen-client-urls=http://127.0.0.1:12379 --advertise-client-urls=http://127.0.0.1:12379 --listen-peer-urls=http://127.0.0.1:12380 --initial-advertise-peer-urls=http://127.0.0.1:12380 --initial-cluster=pa1=http://127.0.0.1:12380,pa2=http://127.0.0.1:22380,pa3=http://127.0.0.1:32380 --initial-cluster-state=new --initial-cluster-token=pingarthur-local -etcd2: etcd --name=pa2 --data-dir=.etcd/pa2 --listen-client-urls=http://127.0.0.1:22379 --advertise-client-urls=http://127.0.0.1:22379 --listen-peer-urls=http://127.0.0.1:22380 --initial-advertise-peer-urls=http://127.0.0.1:22380 --initial-cluster=pa1=http://127.0.0.1:12380,pa2=http://127.0.0.1:22380,pa3=http://127.0.0.1:32380 --initial-cluster-state=new --initial-cluster-token=pingarthur-local -etcd3: etcd --name=pa3 --data-dir=.etcd/pa3 --listen-client-urls=http://127.0.0.1:32379 --advertise-client-urls=http://127.0.0.1:32379 --listen-peer-urls=http://127.0.0.1:32380 --initial-advertise-peer-urls=http://127.0.0.1:32380 --initial-cluster=pa1=http://127.0.0.1:12380,pa2=http://127.0.0.1:22380,pa3=http://127.0.0.1:32380 --initial-cluster-state=new --initial-cluster-token=pingarthur-local - -# sleep 3 lets etcd's leader election settle before the app tries to probe it -pa1: sleep 3 && LISTEN_ADDR=:8080 ETCD_ENDPOINTS=127.0.0.1:12379 INSECURE_DEV_MODE=true go run -tags dev . -pa2: sleep 3 && LISTEN_ADDR=:8081 ETCD_ENDPOINTS=127.0.0.1:22379 INSECURE_DEV_MODE=true go run -tags dev . -pa3: sleep 3 && LISTEN_ADDR=:8082 ETCD_ENDPOINTS=127.0.0.1:32379 INSECURE_DEV_MODE=true go run -tags dev . diff --git a/README.md b/README.md index 56a6090..e9871a8 100644 --- a/README.md +++ b/README.md @@ -1,80 +1,19 @@ # PingArthur -Minimal uptime monitoring. Prove URL ownership with a header echo, poll for the latest status. +LLM-first uptime monitoring — https://pingarthur.com -A [divepool.social](https://divepool.social) project. Public instance at [pingarthur.com](https://pingarthur.com). Source at [tangled.org/divepool.social/pingarthur](https://tangled.org/divepool.social/pingarthur). +**This repository is a placeholder.** PingArthur's code was folded into the +divepool monorepo (divepool ADR-0042, 2026-07): same service, same plane, one +repo to maintain. Self-serve API onboarding was retired at the same time — the +MCP connector is the onboarding: -## How it works +1. Add `https://pingarthur.com/mcp` as an MCP connector in your agent. +2. Sign in with a dedicated Bluesky **status account** (empty repo). Alerts + post as that account on every status change. +3. Ask your agent to `register_monitor` your HTTPS URL, then echo the returned + token verbatim in the `X-PingArthur-Token` header of every HEAD response. -1. `POST /api/v1/tokens` → receive a `pa_<...>` bearer (1 h bind window). -2. Make every HEAD response on the URL you want monitored return `X-PingArthur-Token: ` (verbatim, no `Bearer ` prefix). Deploy this *before* step 3. -3. `POST /api/v1/monitors` with `Authorization: Bearer ` and `{"url": "..."}` → PingArthur HEADs the URL, confirms the echo, and starts checking on a 60-minute cadence. -4. `GET /api/v1/status` (same bearer) → latest observation plus up to 10 recent status changes. -5. To stop monitoring: remove the echo header. After 24 h with no echo, the monitor and all its data are deleted from etcd. **There is no DELETE endpoint by design** — the bearer is a non-secret capability URL, so URL-echo ownership is the only authority honored for write actions. +Details for agents: https://pingarthur.com/llms.txt -The full protocol, all limits, and all timeouts are in [`openapi.json`](openapi.json), served live at [`/api/v1/openapi`](https://pingarthur.com/api/v1/openapi). - -## What it records - -Per HEAD probe: HTTP status code, `X-Health-Status` header value (rune-safe truncated to 280 runes — sized to drop-in post to a Bluesky post), normalized error category. Nothing else. **Assume monitoring data is public** — anyone with the bearer can read status. - -## Optional: status posts on Bluesky - -Bind with an `atmosphere` block (`{handle, app_password}`) and PingArthur posts a Bluesky record (`app.bsky.feed.post`) on the bound account every time the observation tuple changes. Post body is your `X-Health-Status` header verbatim — fully customizable from the target. Use a dedicated account; bind rejects accounts whose `app.bsky.feed.post` collection is non-empty. App passwords are encrypted at rest (AES-256-GCM). - -Auth is ATProto-generic, so any conforming PDS works (bsky.social or self-hosted). The post lexicon is Bluesky's, so posts render in the Bluesky app and any AppView that ingests Bluesky lexicons. - -## Constraints - -- HTTPS + HEAD only. IPv4 only. No redirects. -- 10 active monitors per eTLD+1. Global cap configurable via `MAX_MONITORS` (default 10 000). -- HEAD timeout 10 s; DNS and TLS handshake 3 s each. -- SSRF-guarded dialer: private / CGNAT / link-local / multicast IPs blocked at dial. -- First check at `now + uniform [0, 5 s)`; subsequent checks on the fixed grid `scheduled + interval`. Operators who want a specific phase bind at a chosen wall-clock — the 5 s jitter is small enough that bind moment dominates phase. - -## Self-hosting - -Designed as a 3-node cluster: each node runs the same Go binary + a local etcd member, behind DNS round-robin. Losing any single node leaves the service live (etcd quorum holds with 2/3). - -Full deployment automation lives in [`ansible/`](ansible/) — bootstrap → etcd cluster → app + Caddy + TLS (DNS-01). The reference deployment runs on three Hetzner CAX11s (ARM Ampere, one per zone). See [`CLAUDE.md`](CLAUDE.md) for the architecture, [`ansible/README.md`](ansible/README.md) for the deploy runbook. - -## Local development - -```bash -cp .env.example .env # fill in TOKEN_HMAC_SECRET and ATMOSPHERE_ENC_KEY - # `openssl rand -hex 32` for each -overmind start # spins up 3 etcd + 3 pingarthur (per Procfile) -``` - -The dev binary builds with `-tags dev` and requires `INSECURE_DEV_MODE=true` — both gates must be set. This relaxes bare-IP URLs, private-CIDR dial, and TLS verify, so loopback testing works. The production binary excludes the relaxed code path via the build tag, so it can never enter that path even if the env is set. See [`dev_mode_dev.go`](dev_mode_dev.go). - -`go test ./...` and `go test -tags dev ./...` should both pass before any deploy. - -## Self-test loop - -The canary at [`canary.pingarthur.com`](https://canary.pingarthur.com) is a deliberately-misbehaving HTTP target running on a separate failure domain. Its [`/sync-check`](https://canary.pingarthur.com/sync-check) endpoint fetches PingArthur's view of the canary, computes what PingArthur *should* have recorded at that moment, and returns 200 "in sync" or 503 with a one-line drift reason. A second PingArthur monitor binds against `/sync-check`, so pipeline correctness — not just node liveness — is observable from outside the system. See [`cmd/canary/`](cmd/canary/) and [`ansible/bind_meta.yml`](ansible/bind_meta.yml). - -## How this was built - -Mostly written with Claude (Anthropic), under close human review. The repository's [`CLAUDE.md`](CLAUDE.md) — scope, non-goals, architecture, conventions, key surfaces — is exactly the brief the model worked from, and is the most accurate single document about how the system is meant to behave. The implementation plan in [`PLAN.md`](PLAN.md) is the as-built record of how v0 was sequenced. - -The work that doesn't show up as lines of code: nailing down what *not* to build (capability-URL trust model, no DELETE endpoint, no users, no histories, no leader election); designing the data model so etcd alone is enough; getting the SSRF dialer, the two-phase bind with TOCTOU rollback, and the cluster-wide claim-once semantics right; and the operational shape — rolling Ansible plays, DNS round-robin, Tailscale-only peer plane, Prometheus + `/status` as the operator contract, the canary self-test loop. Every line still went through human judgement on whether it earned its place. - -## What's next - -Nothing on the roadmap is a hard commitment — v0 is the thing that ships. Likely directions, in rough priority: - -- **Notification channels beyond Atmosphere/Bluesky**: webhooks, email, more ATProto lexicons. Posting tuple changes is the primitive; the rest is fan-out. -- **A second canary failure mode**: the current canary catches checker correctness; a separate one for token-rotation / re-bind paths would close another loop. -- **Operator UX**: the admin endpoint is intentionally minimal; richer cluster introspection would help self-hosters. -- **Federation-friendly auth options**: explore whether a non-bearer ownership proof (e.g. signed challenge over the URL itself) is worth the complexity. - -Issues, ideas, and "have you considered…" are welcome — open them on tangled, or DM [`@pingarthur.com`](https://bsky.app/profile/pingarthur.com) on Bluesky. - -## Reporting security issues - -See [`SECURITY.md`](SECURITY.md). TL;DR: DM [`@pingarthur.com`](https://bsky.app/profile/pingarthur.com) on Bluesky. - -## License - -MIT. See [`LICENSE`](LICENSE). +Security reports: see [SECURITY.md](SECURITY.md) — private DM to +[@pingarthur.com](https://bsky.app/profile/pingarthur.com). diff --git a/SECURITY.md b/SECURITY.md index 3b0b6f7..c7ac184 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,9 +12,9 @@ Please include: ## Scope In scope: -- The Go service in this repository. -- The OpenAPI surface served at `pingarthur.com`. -- The Ansible playbooks in `ansible/`. +- The service at `pingarthur.com` (its code now lives in the divepool + monorepo — this repository is a placeholder; reports are still welcome here). +- The MCP/OAuth onboarding surface at `pingarthur.com/mcp` and `/oauth/*`. Out of scope: - Self-hosted deployments not maintained by us. diff --git a/admin.go b/admin.go deleted file mode 100644 index 05af024..0000000 --- a/admin.go +++ /dev/null @@ -1,143 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "log" - "net/http" - "strings" - "time" - - clientv3 "go.etcd.io/etcd/client/v3" -) - -// Admin routes live on the main mux but are NOT in the Caddyfile's -// handle block — public requests to /admin/* fall through to the terse -// landing response. Only way in is via loopback from inside a pingarthur -// node (SSH moat). The bearer-token check here is defense-in-depth, not -// the primary boundary. -// -// Kept out of openapi.json on purpose: the public spec is the client -// contract, these routes are operator surface only. - -const ( - adminMinIntervalSeconds = 60 - adminMaxIntervalSeconds = 86400 -) - -// adminUpdateMonitorHandler serves PATCH /admin/v1/monitors — lets an -// operator promote specific monitors to faster (or slower) cadences -// without dropping into etcdctl surgery. Today it only accepts -// interval_seconds; extend the body shape when new tunables arrive. -func adminUpdateMonitorHandler(secret []byte, etcd *clientv3.Client) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPatch { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - token := extractBearer(r) - if token == "" { - http.Error(w, "missing bearer token", http.StatusUnauthorized) - return - } - if !verifyTokenSignature(secret, token) { - http.Error(w, "invalid bearer token", http.StatusUnauthorized) - return - } - - var body struct { - IntervalSeconds int64 `json:"interval_seconds"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "invalid JSON body", http.StatusBadRequest) - return - } - if body.IntervalSeconds < adminMinIntervalSeconds || body.IntervalSeconds > adminMaxIntervalSeconds { - http.Error(w, "interval_seconds must be between 60 and 86400", http.StatusBadRequest) - return - } - - tokHash := tokenHash(token) - next, err := applyIntervalUpdate(r.Context(), etcd, tokHash, body.IntervalSeconds) - if errors.Is(err, errMonitorNotFound) { - http.Error(w, "monitor not found", http.StatusNotFound) - return - } - if err != nil { - log.Fatalf("admin update monitor: %v", err) - } - - respBody, err := json.Marshal(struct { - IntervalSeconds int64 `json:"interval_seconds"` - NextCheckAt time.Time `json:"next_check_at"` - }{body.IntervalSeconds, next}) - if err != nil { - log.Fatalf("marshal admin response: %v", err) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(respBody) - } -} - -// errMonitorNotFound lets adminUpdateMonitorHandler distinguish the -// 404 case from unexpected etcd errors (which stay fatal). -var errMonitorNotFound = errors.New("monitor not found") - -// applyIntervalUpdate mutates the blob's IntervalSeconds and re-seeds -// the due/ grid at a fresh random offset in [0, interval). The CAS on -// the blob's ModRevision catches concurrent checker writes; the due/ -// cleanup happens first so checker can't race us by claiming the old -// due entry after we write the new one. -func applyIntervalUpdate(ctx context.Context, etcd *clientv3.Client, tokHash string, intervalSeconds int64) (time.Time, error) { - blobResp, err := etcd.Get(ctx, monitorKey(tokHash)) - if err != nil { - return time.Time{}, err - } - if blobResp.Count == 0 { - return time.Time{}, errMonitorNotFound - } - var blob monitorBlob - if err := json.Unmarshal(blobResp.Kvs[0].Value, &blob); err != nil { - return time.Time{}, err - } - blob.IntervalSeconds = intervalSeconds - blobBytes, err := json.Marshal(blob) - if err != nil { - return time.Time{}, err - } - - // Delete any existing due/ entries for this tokhash before writing - // the new one. Outside the CAS txn — worst case a checker claims - // the stale due in the gap and performs one extra check on the old - // schedule. Harmless. - dueResp, err := etcd.Get(ctx, keyPrefixDue, clientv3.WithPrefix(), clientv3.WithKeysOnly()) - if err != nil { - return time.Time{}, err - } - for _, kv := range dueResp.Kvs { - if strings.HasSuffix(string(kv.Key), "/"+tokHash) { - if _, err := etcd.Delete(ctx, string(kv.Key)); err != nil { - return time.Time{}, err - } - } - } - - next := firstCheckAt(time.Now().UTC(), time.Duration(intervalSeconds)*time.Second) - - commit, err := etcd.Txn(ctx). - If(clientv3.Compare(clientv3.ModRevision(monitorKey(tokHash)), "=", blobResp.Kvs[0].ModRevision)). - Then( - clientv3.OpPut(monitorKey(tokHash), string(blobBytes)), - clientv3.OpPut(dueKey(next, tokHash), ""), - ). - Commit() - if err != nil { - return time.Time{}, err - } - if !commit.Succeeded { - // Blob changed under us (checker running). Rare; let the operator retry. - return time.Time{}, errors.New("blob modified concurrently; retry") - } - return next, nil -} diff --git a/admin_test.go b/admin_test.go deleted file mode 100644 index 0f4aa66..0000000 --- a/admin_test.go +++ /dev/null @@ -1,148 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - clientv3 "go.etcd.io/etcd/client/v3" -) - -func adminReq(method, token, jsonBody string) *http.Request { - r := httptest.NewRequest(method, "/admin/v1/monitors", strings.NewReader(jsonBody)) - r.Body = http.MaxBytesReader(nil, r.Body, maxRequestBodyBytes) - if token != "" { - r.Header.Set("Authorization", "Bearer "+token) - } - r.Header.Set("Content-Type", "application/json") - return r -} - -func TestAdmin_UpdateInterval_HappyPath(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - tok, tokHash := bindOne(t, cli, secret, true) - - h := adminUpdateMonitorHandler(secret, cli) - - rec := httptest.NewRecorder() - h.ServeHTTP(rec, adminReq(http.MethodPatch, tok, `{"interval_seconds":60}`)) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d (%s); want 200", rec.Code, rec.Body.String()) - } - - // Blob's IntervalSeconds is persisted. - blob, _ := readBlob(t, cli, tokHash) - if blob.IntervalSeconds != 60 { - t.Errorf("blob.IntervalSeconds = %d; want 60", blob.IntervalSeconds) - } - - // Exactly one due/ entry for this tokhash, scheduled ≤ 60s ahead. - dueResp, _ := cli.Get(context.Background(), keyPrefixDue, clientv3.WithPrefix()) - matching := 0 - for _, kv := range dueResp.Kvs { - if strings.HasSuffix(string(kv.Key), "/"+tokHash) { - matching++ - _, ts, _ := parseDueKey(string(kv.Key)) - if until := time.Until(ts); until > 61*time.Second { - t.Errorf("rescheduled due too far out: %v (now+%v); want ≤ 60s", ts, until) - } - } - } - if matching != 1 { - t.Errorf("due/ entries for tokhash = %d; want 1", matching) - } - - // Response body shape. - var resp struct { - IntervalSeconds int64 `json:"interval_seconds"` - NextCheckAt time.Time `json:"next_check_at"` - } - if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { - t.Fatalf("decode: %v", err) - } - if resp.IntervalSeconds != 60 { - t.Errorf("resp.IntervalSeconds = %d; want 60", resp.IntervalSeconds) - } - if resp.NextCheckAt.IsZero() { - t.Error("resp.NextCheckAt is zero") - } -} - -func TestAdmin_UpdateInterval_NoBearer_401(t *testing.T) { - cli := startEmbeddedEtcd(t) - h := adminUpdateMonitorHandler(mustSecret(t), cli) - - rec := httptest.NewRecorder() - h.ServeHTTP(rec, adminReq(http.MethodPatch, "", `{"interval_seconds":60}`)) - if rec.Code != http.StatusUnauthorized { - t.Errorf("status = %d; want 401", rec.Code) - } -} - -func TestAdmin_UpdateInterval_BadBearer_401(t *testing.T) { - cli := startEmbeddedEtcd(t) - h := adminUpdateMonitorHandler(mustSecret(t), cli) - - rec := httptest.NewRecorder() - h.ServeHTTP(rec, adminReq(http.MethodPatch, "pa_garbage", `{"interval_seconds":60}`)) - if rec.Code != http.StatusUnauthorized { - t.Errorf("status = %d; want 401", rec.Code) - } -} - -func TestAdmin_UpdateInterval_NoMonitor_404(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - // Valid HMAC, but no monitor bound for this token's hash. - tok := issueTestToken(secret) - h := adminUpdateMonitorHandler(secret, cli) - - rec := httptest.NewRecorder() - h.ServeHTTP(rec, adminReq(http.MethodPatch, tok, `{"interval_seconds":60}`)) - if rec.Code != http.StatusNotFound { - t.Errorf("status = %d; want 404", rec.Code) - } -} - -func TestAdmin_UpdateInterval_OutOfRange_400(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - tok, _ := bindOne(t, cli, secret, true) - h := adminUpdateMonitorHandler(secret, cli) - - for _, body := range []string{ - `{"interval_seconds":30}`, // under min (60) - `{"interval_seconds":999999}`, // over max (86400) - `{"interval_seconds":0}`, // zero / missing - } { - rec := httptest.NewRecorder() - h.ServeHTTP(rec, adminReq(http.MethodPatch, tok, body)) - if rec.Code != http.StatusBadRequest { - t.Errorf("body=%s: status = %d; want 400", body, rec.Code) - } - } -} - -// Smoke check that only PATCH is accepted — belt-and-suspenders in case -// someone wires the handler in without the method restriction in mux. -func TestAdmin_UpdateInterval_WrongMethod_405(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - tok, _ := bindOne(t, cli, secret, true) - h := adminUpdateMonitorHandler(secret, cli) - - rec := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodPost, "/admin/v1/monitors", bytes.NewReader([]byte(`{"interval_seconds":60}`))) - r.Header.Set("Authorization", "Bearer "+tok) - h.ServeHTTP(rec, r) - if rec.Code != http.StatusMethodNotAllowed { - t.Errorf("status = %d; want 405", rec.Code) - } -} diff --git a/ansible/README.md b/ansible/README.md deleted file mode 100644 index c3973b2..0000000 --- a/ansible/README.md +++ /dev/null @@ -1,199 +0,0 @@ -# Ansible - -Provisions 3 Hetzner CAX11s as PingArthur cluster nodes. Starts from -"I have 3 IPs with passwordless root SSH"; leaves you with hardened -boxes joined to Tailscale, nftables default-deny, and the `pingarthur` -user ready for app deploys. - -## One-time setup - -1. Install a recent `ansible` (≥ 2.15 for `deb822_repository`): - ```sh - brew install ansible - ansible-galaxy collection install ansible.posix - ``` - -2. Copy the inventory template and fill in real IPs: - ```sh - cp inventory.example.yml inventory.yml - # edit inventory.yml — gitignored, keeps public IPs out of the repo - ``` - -3. Provision secrets: - ```sh - cp group_vars/all/vault.yml.example group_vars/all/vault.yml - # edit: paste your Tailscale reusable+ephemeral auth key - ansible-vault encrypt group_vars/all/vault.yml - ``` - -4. Make sure your admin SSH pubkey is at `~/.ssh/id_ed25519.pub`, or - override `admin_pubkey_path` in `group_vars/all/vars.yml`. - -## Running bootstrap - -```sh -cd ansible -ansible-playbook bootstrap.yml --ask-vault-pass -``` - -What it does (in order): -- Creates `pingarthur` user + installs your pubkey + scoped sudoers -- Installs base packages (tailscale, nftables, unattended-upgrades) -- Loads nftables ruleset (default-deny public, all-accept tailscale0) -- Joins Tailscale using the vaulted auth key -- Enables unattended-upgrades -- **Last**: verifies SSH to `pingarthur@` works, then disables root SSH + - password auth. The pre-check is the safety net — if it fails, root - stays enabled and the run aborts. - -After first success, edit `inventory.yml` → `ansible_user: pingarthur`. - -## Running etcd cluster deploy - -```sh -ansible-playbook etcd.yml --ask-vault-pass -``` - -Run this once after `bootstrap.yml`. It: -- Discovers each node's Tailscale IPv4 (via `tailscale ip -4`) -- Downloads the etcd ARM64 release tarball, installs `etcd` + `etcdctl` - to `/usr/local/bin` -- Renders `/etc/etcd/etcd.conf` with peer URLs on the Tailscale IP and - client URLs on `127.0.0.1:2379` -- Installs the systemd unit, enables + starts it - -Verify after first run: -```sh -ssh pingarthur@pa-fsn1 "sudo etcdctl --endpoints=http://127.0.0.1:2379 member list" -# Should list 3 members; same on the other 2 nodes. - -# From outside Tailscale: 2379-2380 must be filtered/closed. -nmap -p 2379,2380 -``` - -Bumping `etcd_version` in the playbook re-downloads + restarts. Removing -or adding members later is an `etcdctl member add/remove` against the -running cluster, not a re-run of this playbook. - -## Running app deploy - -```sh -ansible-playbook deploy.yml --ask-vault-pass -``` - -Runs after `bootstrap.yml` + `etcd.yml`. It: -- Cross-compiles `pingarthur` for `linux/arm64` via `make build-linux-arm64` -- Rsyncs the binary to `/usr/local/bin/pingarthur` on each node -- Renders `/etc/pingarthur/env` from the vault (TOKEN_HMAC_SECRET) -- Installs `pingarthur.service`, enables + starts it -- Installs Caddy, renders the Caddyfile (TLS for `pingarthur_domain`, - reverse-proxies only `/api/v1/*` and `/status` — `/metrics` stays - loopback-only) - -Re-running is idempotent: `make` rebuilds, Ansible replaces the -binary + config, handlers fire only on changes. - -Verify after first run: -```sh -curl https://pingarthur.com/status # expect 200 "ok\n" -curl https://pingarthur.com/metrics # expect 404 / terse landing -curl -X POST https://pingarthur.com/api/v1/tokens -``` - -DNS remains manual (3 A records pointing at the public IPs, TTL ~60s). - -## Running TLS issuance + renewal - -```sh -ansible-playbook tls.yml --ask-vault-pass -``` - -Runs after `deploy.yml`. Uses `lego` + DNS-01 via united-domains from the -controller to obtain the `pingarthur.com` cert, then distributes the -cert + key to `/etc/caddy/certs/` on all 3 nodes and reloads Caddy. - -Static cert (not Caddy auto-HTTPS) because multi-node auto-HTTPS racing -across DNS round-robin fails Let's Encrypt's secondary validation (each -validator lands on a different A record, sees a different challenge -cert). - -Re-run this playbook any time; lego's `renew` is a no-op outside the -30-day renewal window. For automation, add a monthly cron that runs -this playbook — though since the `--ask-vault-pass` prompt is -interactive, an unattended cron would need a vault password file. - -## Deploying the canary - -The canary (`cmd/canary` in the repo root) is a small Go service on -divepool edge that cycles through deliberately-broken HTTP behaviours -on a wall-clock schedule. Pingarthur monitors it to prove its own -pipeline works. Host lives outside pingarthur's failure domain on -purpose. - -One-time setup: - -1. Add the `canary` group host to `inventory.yml` (divepool edge IP). -2. Mint a token: `curl -X POST https://pingarthur.com/api/v1/tokens`. -3. Add it to vault as `vault_canary_echo_token: "pa_..."`. -4. Run the play: - ```sh - ansible-playbook canary.yml - ``` -5. In the divepool repo: `system/Caddyfile` has been extended with the - `canary.pingarthur.com` site block. Deploy just the Caddyfile: - ```sh - cd ~/projects/divepool - ansible-playbook system/playbooks/deploy.yml -e service=caddyfile - ``` -6. Add DNS A record: `canary.pingarthur.com` → divepool edge IP (at - united-domains — overrides the wildcard via more-specific match). -7. Bind the canary URL into pingarthur: - ```sh - curl -X POST https://pingarthur.com/api/v1/monitors \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"url":"https://canary.pingarthur.com/"}' - ``` -8. Force 60s interval on the canary monitor. SSH to any pingarthur node: - ```sh - TOK=pa_... # the same token you bound - TOKHASH=$(printf '%s' "$TOK" | sha256sum | awk '{print $1}') - # Patch the blob's interval_seconds field: - sudo etcdctl --endpoints=http://127.0.0.1:2379 get monitor/$TOKHASH --print-value-only \ - | jq '.interval_seconds = 60' \ - | sudo etcdctl --endpoints=http://127.0.0.1:2379 put monitor/$TOKHASH - # Re-seed the due/ key a few seconds out: - sudo etcdctl --endpoints=http://127.0.0.1:2379 del --prefix due/ \ - --range-end=$(sudo etcdctl --endpoints=http://127.0.0.1:2379 get --prefix --keys-only due/ | grep $TOKHASH) - sudo etcdctl --endpoints=http://127.0.0.1:2379 put due/$(printf '%020d' $(($(date +%s) + 10)))/$TOKHASH "" - ``` - -Verify: `curl -H "Authorization: Bearer $TOKEN" https://pingarthur.com/api/v1/status` -should show history growing by one entry per minute, with tuples -matching the cycle in order: -1. `(200, "healthy", "")` -2. `(200, "degraded", "")` -3. `(503, "", "")` -4. `(200, "", "token_mismatch")` -5. `(0, "", "timeout")` - -## Files - -- `ansible.cfg` — minimal defaults + ssh connection tuning -- `inventory.yml` — hosts + per-group vars -- `group_vars/all/vars.yml` — non-secret config (vault aliases, domain, etc.) -- `group_vars/all/vault.yml` — encrypted secrets (gitignored) -- `bootstrap.yml` — one-shot host bootstrap (user, ssh, firewall, tailscale) -- `etcd.yml` — install + run etcd cluster across the mesh -- `deploy.yml` — build + ship the app binary, install Caddy + base config -- `tls.yml` — obtain + distribute the TLS cert (DNS-01 via lego) -- `canary.yml` — build + ship the canary to divepool edge -- `files/nftables.conf` — firewall ruleset (rendered as-is) -- `files/sshd_hardening.conf` — sshd drop-in -- `files/etcd.service` — systemd unit for etcd -- `files/pingarthur.service` — systemd unit for the app -- `files/canary.service` — systemd unit for the canary -- `templates/etcd.conf.j2` — per-node etcd EnvironmentFile -- `templates/pingarthur.env.j2` — app EnvironmentFile (holds HMAC secret) -- `templates/Caddyfile.j2` — public reverse-proxy config (static cert) -- `templates/canary.env.j2` — canary EnvironmentFile (holds echo token) diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg deleted file mode 100644 index e83d405..0000000 --- a/ansible/ansible.cfg +++ /dev/null @@ -1,15 +0,0 @@ -[defaults] -inventory = inventory.yml -host_key_checking = False -# Readable output: the default callback renders each task's result inline, -# and result_format=yaml makes multi-line fields (diffs, err strings) legible. -# `stdout_callback = yaml` from community.general was removed in ansible-core 2.13. -stdout_callback = default -result_format = yaml -# SSH pubkey-based access is the expectation once bootstrap is past phase 1. -# Setting retry files off because we rewrite playbooks frequently. -retry_files_enabled = False - -[ssh_connection] -ssh_args = -o ControlMaster=auto -o ControlPersist=60s -pipelining = True diff --git a/ansible/bind_meta.yml b/ansible/bind_meta.yml deleted file mode 100644 index 4481e4e..0000000 --- a/ansible/bind_meta.yml +++ /dev/null @@ -1,261 +0,0 @@ ---- -# Mints fresh pa_ bearers, pushes them to canary as echo tokens, binds the -# meta-monitors through the public API, and patches their interval to the -# value declared in meta_monitors.yml. End-to-end automation of what the -# operator otherwise does by hand: -# -# for each meta_monitor: -# POST /api/v1/tokens → bearer -# write bearer into canary env → canary echoes it -# POST /api/v1/monitors {url, atmo?} → monitor bound -# PATCH /admin/v1/monitors {interval} → interval forced to 60 s -# -# Inputs: -# - meta_monitors (group_vars/all/meta_monitors.yml; plaintext) -# - inventory groups `canary` and `pingarthur` -# -# Outputs: -# - group_vars/all/echo_tokens.yml rewritten with the freshly-minted -# bearers, so canary.yml on its own can re-deploy without re-binding. -# - the bearer values are also stashed in a play-scoped fact for the -# bind/PATCH steps; never logged. -# -# Idempotency: each run creates NEW monitors (because the bearer is fresh -# every time, and the tokHash is sha256(bearer)). Old monitors auto-stop -# after 24 h once their tokens stop echoing. This is fine for the meta- -# monitors specifically; do NOT generalise this play to user monitors. -# -# Phase: by default the play sleeps until wall-clock seconds == 30 before -# binding, so each monitor's fixed phase lands ~25 s clear of the canary's -# :00 cycle boundary. Skip with `-e align_to_midminute=false`. -# -# Run: -# ansible-playbook bind_meta.yml - -- name: Pre-clean any prior meta-monitor state in etcd - hosts: pingarthur[0] - gather_facts: false - - tasks: - - name: Find existing monitors whose URL is one of the meta-monitors - ansible.builtin.shell: - executable: /bin/bash - cmd: | - set -euo pipefail - urls='{{ meta_monitors | map(attribute="url") | join("|") }}' - etcdctl get --prefix monitor/ -w json | URLS_RE="$urls" python3 -c ' - import sys, json, base64, os, re - urls_re = re.compile("^(?:" + os.environ["URLS_RE"] + ")$") - d = json.load(sys.stdin) - for kv in d.get("kvs", []): - tok_hash = base64.b64decode(kv["key"]).decode().split("/", 1)[1] - blob = json.loads(base64.b64decode(kv["value"]).decode()) - if urls_re.match(blob["url"]): - print(tok_hash, blob["etld1"]) - ' - register: existing_meta - changed_when: false - - - name: Delete each matching monitor + secondary indexes - ansible.builtin.shell: - executable: /bin/bash - cmd: | - set -euo pipefail - h='{{ item.split()[0] }}' - e='{{ item.split()[1] }}' - etcdctl del "monitor/$h" - etcdctl del "etld/$e/$h" - for k in $(etcdctl get --prefix due/ --keys-only | grep -- "$h" || true); do - [ -n "$k" ] && etcdctl del "$k" - done - for k in $(etcdctl get --prefix claimed/ --keys-only | grep -- "$h" || true); do - [ -n "$k" ] && etcdctl del "$k" - done - cur=$(etcdctl get "etld_count/$e" --print-value-only) - new=$((cur - 1)) - if [ "$new" -le 0 ]; then - etcdctl del "etld_count/$e" - else - etcdctl put "etld_count/$e" "$new" - fi - cur=$(etcdctl get "stats/monitor_count" --print-value-only) - new=$((cur - 1)) - etcdctl put "stats/monitor_count" "$new" - loop: "{{ existing_meta.stdout_lines }}" - when: existing_meta.stdout_lines | length > 0 - -- name: Pre-clean atmosphere repos for each bound meta-monitor - hosts: localhost - connection: local - gather_facts: false - - tasks: - - name: Delete every app.bsky.feed.post on each atmosphere account - ansible.builtin.shell: | - python3 <<'PY' - import urllib.request, urllib.error, json, os - handle = os.environ["ATMO_HANDLE"] - pw = os.environ["ATMO_PW"] - sess_body = json.dumps({"identifier": handle, "password": pw}).encode() - sess = json.loads(urllib.request.urlopen(urllib.request.Request( - "https://bsky.social/xrpc/com.atproto.server.createSession", - data=sess_body, headers={"Content-Type": "application/json"}, method="POST" - )).read()) - jwt, did = sess["accessJwt"], sess["did"] - hdr = {"Authorization": "Bearer " + jwt, "Content-Type": "application/json"} - for _ in range(5): - recs = json.loads(urllib.request.urlopen(urllib.request.Request( - f"https://bsky.social/xrpc/com.atproto.repo.listRecords?repo={did}&collection=app.bsky.feed.post&limit=100", - headers=hdr - )).read())["records"] - if not recs: - break - for r in recs: - rkey = r["uri"].split("/")[-1] - body = json.dumps({"repo": did, "collection": "app.bsky.feed.post", "rkey": rkey}).encode() - urllib.request.urlopen(urllib.request.Request( - "https://bsky.social/xrpc/com.atproto.repo.deleteRecord", - data=body, headers=hdr, method="POST" - )).read() - PY - environment: - ATMO_HANDLE: "{{ item.atmosphere.handle }}" - ATMO_PW: "{{ item.atmosphere.app_password }}" - loop: "{{ meta_monitors | selectattr('atmosphere', 'defined') | list }}" - loop_control: - label: "{{ item.atmosphere.handle }}" - no_log: true - changed_when: true - -- name: Mint bearers + push to canary - hosts: canary - gather_facts: false - - vars: - public_base_url: "https://{{ pingarthur_domain }}" - env_dir: /etc/canary - - tasks: - - name: Mint a fresh bearer for each meta-monitor - ansible.builtin.uri: - url: "{{ public_base_url }}/api/v1/tokens" - method: POST - status_code: 200 - return_content: true - delegate_to: localhost - become: false - loop: "{{ meta_monitors }}" - loop_control: - label: "{{ item.name }}" - register: mint_results - no_log: true - - - name: Stash {name → bearer} for downstream tasks - ansible.builtin.set_fact: - meta_bearers: >- - {{ dict(meta_monitors | map(attribute='name') | zip( - mint_results.results | map(attribute='json.token'))) }} - no_log: true - - - name: Render canary env file with freshly-minted echo tokens - become: true - ansible.builtin.template: - src: canary.env.j2 - dest: "{{ env_dir }}/env" - owner: root - mode: "0644" - vars: - canary_echo_token: "{{ meta_bearers['canary'] }}" - sync_check_echo_token: "{{ meta_bearers['sync_check'] }}" - no_log: true - - - name: Restart canary so it picks up the new echo tokens - become: true - ansible.builtin.systemd: - name: canary - state: restarted - - - name: Wait until canary echoes each new bearer on its target URL - ansible.builtin.uri: - url: "{{ item.url }}" - method: HEAD - status_code: [200, 503] - return_content: false - # We only need the response headers; ansible's uri returns them. - delegate_to: localhost - become: false - register: echo_check - until: >- - echo_check.x_pingarthur_token is defined and - echo_check.x_pingarthur_token == meta_bearers[item.name] - retries: 30 - delay: 2 - loop: "{{ meta_monitors }}" - loop_control: - label: "{{ item.name }}" - no_log: true - - - name: Wait for wall-clock :30 so monitor phases land mid-minute - ansible.builtin.shell: | - while [[ "$(date -u +%S)" != "30" ]]; do sleep 0.2; done - delegate_to: localhost - become: false - changed_when: false - when: align_to_midminute | default(true) | bool - - - name: Bind each meta-monitor via the public API - ansible.builtin.uri: - url: "{{ public_base_url }}/api/v1/monitors" - method: POST - status_code: 201 - return_content: true - headers: - Authorization: "Bearer {{ meta_bearers[item.name] }}" - Content-Type: application/json - body_format: json - body: >- - {{ {'url': item.url} - | combine( - ('atmosphere' in item) - | ternary({'atmosphere': item.atmosphere | default({})}, {})) }} - delegate_to: localhost - become: false - loop: "{{ meta_monitors }}" - loop_control: - label: "{{ item.name }}" - - - name: Persist freshly-minted bearers to echo_tokens.yml (audit trail) - delegate_to: localhost - become: false - ansible.builtin.copy: - dest: "{{ playbook_dir }}/group_vars/all/echo_tokens.yml" - mode: "0644" - content: | - --- - # Auto-generated by bind_meta.yml. Plaintext on purpose — these - # are pa_ bearers the canary echoes publicly. canary.yml reads - # this file to re-render the env when redeploying without going - # through the full bind cycle. - canary_echo_token: "{{ meta_bearers['canary'] }}" - sync_check_echo_token: "{{ meta_bearers['sync_check'] }}" - -- name: Patch each meta-monitor's interval via loopback admin - hosts: pingarthur[0] - gather_facts: false - - tasks: - - name: PATCH /admin/v1/monitors → interval_seconds - ansible.builtin.uri: - url: "http://127.0.0.1:8080/admin/v1/monitors" - method: PATCH - status_code: 200 - headers: - Authorization: "Bearer {{ hostvars[groups['canary'][0]]['meta_bearers'][item.name] }}" - Content-Type: application/json - body_format: json - body: - interval_seconds: "{{ item.interval_seconds }}" - loop: "{{ meta_monitors }}" - loop_control: - label: "{{ item.name }}" - no_log: true diff --git a/ansible/bootstrap.yml b/ansible/bootstrap.yml deleted file mode 100644 index 7cb82cd..0000000 --- a/ansible/bootstrap.yml +++ /dev/null @@ -1,179 +0,0 @@ ---- -# One-shot bootstrap. Starts from 3 fresh Hetzner CAX11s with passwordless -# root SSH and leaves them with: -# - pingarthur system user + admin SSH key installed -# - sudoers drop-in (passwordless systemctl on pingarthur + etcd units only) -# - Tailscale installed, joined, tailscale0 up -# - nftables default-deny on public interface, permissive on tailscale0 -# - unattended-upgrades enabled -# - Root SSH + password auth disabled (last — by the time this runs, -# tailscale gives us an alternate path in if something breaks) -# -# Run: -# ansible-playbook bootstrap.yml --ask-vault-pass -# -# After a successful run, set ansible_user=pingarthur in inventory for all -# future invocations. - -- name: Bootstrap PingArthur cluster nodes - hosts: pingarthur - gather_facts: true - become: true - - vars_files: - - vault.yml - - tasks: - # === User + SSH key ===================================================== - - name: Create pingarthur system user - ansible.builtin.user: - name: pingarthur - system: true - shell: /bin/bash - create_home: true - state: present - - - name: Install admin SSH key for pingarthur - ansible.posix.authorized_key: - user: pingarthur - key: "{{ lookup('file', admin_pubkey_path) }}" - state: present - - # Passwordless sudo for pingarthur. The plan originally called for a - # scoped allowlist (systemctl on our units only), but that conflicts - # with Ansible's provisioning path, which needs apt, file writes under - # /etc, etc. In this solo-admin setup, SSH-key access to pingarthur is - # the root credential — scoped sudo is theater when the key already - # grants everything. If multi-admin ever materializes, revisit. - - name: Install sudoers drop-in - ansible.builtin.copy: - dest: /etc/sudoers.d/pingarthur-admin - mode: "0440" - validate: "visudo -cf %s" - content: | - pingarthur ALL=(ALL) NOPASSWD: ALL - - # === Base packages ====================================================== - - name: Ensure /etc/apt/keyrings/ exists - ansible.builtin.file: - path: /etc/apt/keyrings - state: directory - mode: "0755" - - - name: Install Tailscale apt signing key - ansible.builtin.get_url: - url: https://pkgs.tailscale.com/stable/ubuntu/noble.noarmor.gpg - dest: /etc/apt/keyrings/tailscale.gpg - mode: "0644" - - - name: Add Tailscale apt repository (deb822 format) - ansible.builtin.deb822_repository: - name: tailscale - types: deb - uris: https://pkgs.tailscale.com/stable/ubuntu - suites: "{{ ansible_distribution_release }}" - components: main - signed_by: /etc/apt/keyrings/tailscale.gpg - state: present - - - name: Refresh apt cache - ansible.builtin.apt: - update_cache: true - - - name: Install base packages - ansible.builtin.apt: - name: - - tailscale - - nftables - - unattended-upgrades - - rsync - - curl - - ca-certificates - state: present - - # === nftables =========================================================== - - name: Install nftables ruleset - ansible.builtin.copy: - src: nftables.conf - dest: /etc/nftables.conf - mode: "0644" - notify: reload nftables - - - name: Enable nftables at boot - ansible.builtin.systemd: - name: nftables - enabled: true - state: started - - # === Tailscale ========================================================== - - name: Enable tailscaled - ansible.builtin.systemd: - name: tailscaled - enabled: true - state: started - - # `tailscale up` is idempotent — no-ops when already joined with the - # same key. --ssh off because we authenticate via OpenSSH, not Tailscale - # SSH (keeps the auth surface small and well-understood). - - name: Join the tailnet - ansible.builtin.command: - cmd: >- - tailscale up - --authkey={{ tailscale_auth_key }} - --hostname={{ inventory_hostname }} - --ssh=false - --accept-routes=false - --advertise-exit-node=false - register: tailscale_up - changed_when: "'Success' in tailscale_up.stdout or tailscale_up.rc == 0" - no_log: true # auth key in argv; keep it out of ansible output - - # === Unattended upgrades ================================================ - - name: Enable unattended-upgrades via debconf - ansible.builtin.debconf: - name: unattended-upgrades - question: unattended-upgrades/enable_auto_updates - value: "true" - vtype: boolean - - - name: Ensure unattended-upgrades timer runs - ansible.builtin.systemd: - name: unattended-upgrades - enabled: true - state: started - - # === sshd hardening (last — ensures pingarthur SSH is proven before - # locking root out) ==================================================== - - name: Verify pingarthur user SSH works before disabling root - ansible.builtin.command: - cmd: ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o ConnectTimeout=5 - pingarthur@{{ ansible_host }} true - delegate_to: localhost - become: false - changed_when: false - # If this fails, stop immediately — disabling root with broken - # pingarthur SSH = bricked host, recoverable only via rescue console. - - - name: Install sshd hardening drop-in - ansible.builtin.copy: - src: sshd_hardening.conf - dest: /etc/ssh/sshd_config.d/10-pingarthur.conf - mode: "0644" - validate: "sshd -tf %s" - notify: restart sshd - - - name: Drop root's authorized_keys - ansible.builtin.file: - path: /root/.ssh/authorized_keys - state: absent - - handlers: - - name: reload nftables - ansible.builtin.systemd: - name: nftables - state: reloaded - - - name: restart sshd - ansible.builtin.systemd: - name: ssh - state: restarted diff --git a/ansible/canary.yml b/ansible/canary.yml deleted file mode 100644 index c82e591..0000000 --- a/ansible/canary.yml +++ /dev/null @@ -1,93 +0,0 @@ ---- -# Deploys the canary service to a separate host outside the pingarthur -# failure domain. Targets a dedicated `canary` inventory group so the -# deploy host is decoupled from pingarthur and can be moved freely. -# -# Expects one host in the `canary` group in your inventory (gitignored -# inventory.yml), configured to run amd64 + already has its own Caddy -# doing TLS for canary.pingarthur.com. See ansible/README.md for the -# manual host-side bits (Caddy site block + DNS A record). -# -# Run: -# ansible-playbook canary.yml - -- name: Build canary binary for linux/amd64 - hosts: localhost - connection: local - gather_facts: false - tasks: - - name: make build-canary-linux-amd64 - ansible.builtin.command: - cmd: make build-canary-linux-amd64 - chdir: "{{ playbook_dir }}/.." - changed_when: true - -- name: Install + start canary service - hosts: canary - become: true - - vars: - install_dir: /usr/local/bin - env_dir: /etc/canary - local_binary: "{{ playbook_dir }}/../bin/canary-amd64" - - tasks: - - name: Stop canary before overwriting binary (ignore if not yet installed) - ansible.builtin.systemd: - name: canary - state: stopped - failed_when: false - - - name: Upload canary binary - ansible.builtin.copy: - src: "{{ local_binary }}" - dest: "{{ install_dir }}/canary" - mode: "0755" - owner: root - group: root - - - name: Create env dir - ansible.builtin.file: - path: "{{ env_dir }}" - state: directory - mode: "0755" - owner: root - group: root - - - name: Render canary env file (holds CANARY_ECHO_TOKEN) - ansible.builtin.template: - src: canary.env.j2 - dest: "{{ env_dir }}/env" - owner: root - # DynamicUser runs as a random UID each start, but the systemd - # default allows reading EnvironmentFile paths that are world- - # readable. 0644 keeps that path simple. - mode: "0644" - no_log: true - notify: restart canary - - - name: Install canary systemd unit - ansible.builtin.copy: - src: canary.service - dest: /etc/systemd/system/canary.service - mode: "0644" - notify: - - reload systemd - - restart canary - - - name: Enable + start canary - ansible.builtin.systemd: - name: canary - enabled: true - state: started - daemon_reload: true - - handlers: - - name: reload systemd - ansible.builtin.systemd: - daemon_reload: true - - - name: restart canary - ansible.builtin.systemd: - name: canary - state: restarted diff --git a/ansible/deploy.yml b/ansible/deploy.yml deleted file mode 100644 index 371a31d..0000000 --- a/ansible/deploy.yml +++ /dev/null @@ -1,165 +0,0 @@ ---- -# Builds pingarthur locally for linux/arm64, rsyncs to each node, -# installs/refreshes systemd units + Caddyfile, restarts services. -# -# Run: -# ansible-playbook deploy.yml --ask-vault-pass -# -# Prerequisites: -# - bootstrap.yml + etcd.yml have run -# - ansible-vault holds vault_token_hmac_secret -# - local Go toolchain can cross-compile to linux/arm64 - -- name: Deploy pingarthur + Caddy - hosts: pingarthur - become: true - # serial=1 rolls nodes one at a time so at least 2/3 stay serving. - # Public DNS round-robin + Caddy's ~1-2s restart window means clients - # see no outage; in-flight requests drain via srv.Shutdown() in main.go. - # Trade-off: full deploy takes ~3× as long. Worth it. - serial: 1 - - vars_files: - - vault.yml - - vars: - project_root: "{{ playbook_dir }}/.." - install_dir: /usr/local/bin - env_dir: /etc/pingarthur - - tasks: - # === Build locally (once, run_once) ==================================== - - name: Build pingarthur binary for linux/arm64 - delegate_to: localhost - become: false - run_once: true - ansible.builtin.command: - cmd: make build-linux-arm64 - chdir: "{{ project_root }}" - changed_when: true # let `make` decide; we just rebuild every deploy - - # === Binary ============================================================ - - name: Stop pingarthur before overwriting binary - ansible.builtin.systemd: - name: pingarthur - state: stopped - failed_when: false # first deploy — unit doesn't exist yet - - - name: Upload pingarthur binary - ansible.builtin.copy: - src: "{{ project_root }}/bin/pingarthur-arm64" - dest: "{{ install_dir }}/pingarthur" - mode: "0755" - owner: root - group: root - - # === Env file (holds TOKEN_HMAC_SECRET) ================================ - - name: Create env dir - ansible.builtin.file: - path: "{{ env_dir }}" - state: directory - owner: root - group: pingarthur - mode: "0750" - - - name: Render env file - ansible.builtin.template: - src: pingarthur.env.j2 - dest: "{{ env_dir }}/env" - owner: root - group: pingarthur - mode: "0640" - no_log: true - notify: restart pingarthur - - # === Systemd unit ====================================================== - - name: Install pingarthur systemd unit - ansible.builtin.copy: - src: pingarthur.service - dest: /etc/systemd/system/pingarthur.service - mode: "0644" - notify: - - reload systemd - - restart pingarthur - - - name: Enable + start pingarthur - ansible.builtin.systemd: - name: pingarthur - enabled: true - state: started - daemon_reload: true - - # === Caddy ============================================================= - - name: Download Caddy apt signing key (armored) - ansible.builtin.get_url: - url: https://dl.cloudsmith.io/public/caddy/stable/gpg.key - dest: /tmp/caddy-signing.asc - mode: "0644" - - - name: Dearmor Caddy signing key - ansible.builtin.command: - cmd: "gpg --dearmor -o /etc/apt/keyrings/caddy.gpg /tmp/caddy-signing.asc" - creates: /etc/apt/keyrings/caddy.gpg - - - name: Add Caddy apt repository (deb822) - ansible.builtin.deb822_repository: - name: caddy-stable - types: deb - uris: https://dl.cloudsmith.io/public/caddy/stable/deb/debian - suites: any-version - components: main - signed_by: /etc/apt/keyrings/caddy.gpg - state: present - - - name: Install Caddy - ansible.builtin.apt: - name: caddy - state: present - update_cache: true - - - name: Render Caddyfile - ansible.builtin.template: - src: Caddyfile.j2 - dest: /etc/caddy/Caddyfile - owner: root - group: root - mode: "0644" - # No `validate: caddy validate` here: Caddy's validate subcommand - # fully provisions the config, which tries to open the static TLS - # cert files. Those are written later by tls.yml — running deploy - # before tls the first time would fail the validate step. Syntax - # errors surface on reload instead (caddy reload also validates). - - # Whether to bring Caddy up during deploy depends on whether the - # static cert is already in place. First time through this is false - # (tls.yml hasn't placed certs yet); subsequent deploys it's true. - - name: Check whether TLS cert is already on disk - ansible.builtin.stat: - path: "/etc/caddy/certs/{{ pingarthur_domain }}.crt" - register: caddy_cert - - - name: Enable caddy at boot - ansible.builtin.systemd: - name: caddy - enabled: true - - - name: Start + reload caddy (cert already present) - ansible.builtin.systemd: - name: caddy - state: reloaded - when: caddy_cert.stat.exists - - handlers: - - name: reload systemd - ansible.builtin.systemd: - daemon_reload: true - - - name: restart pingarthur - ansible.builtin.systemd: - name: pingarthur - state: restarted - - - name: reload caddy - ansible.builtin.systemd: - name: caddy - state: reloaded diff --git a/ansible/etcd.yml b/ansible/etcd.yml deleted file mode 100644 index 05035dd..0000000 --- a/ansible/etcd.yml +++ /dev/null @@ -1,145 +0,0 @@ ---- -# Installs the etcd ARM64 release as a systemd unit and joins the 3-node -# cluster across Tailscale. Run after bootstrap.yml (which installs -# Tailscale and brings up the mesh). -# -# Idempotent: re-running with the same etcd_version is a no-op; bumping -# the version downloads + replaces the binary and restarts the unit. -# -# Run: -# ansible-playbook etcd.yml --ask-vault-pass - -# Phase 1 — discover each node's Tailscale IP. Stored as a host fact so -# the second play can build --initial-cluster from hostvars. -- name: Discover Tailscale IPv4 - hosts: pingarthur - gather_facts: false - tasks: - - name: Read tailscale ip -4 - ansible.builtin.command: tailscale ip -4 - register: _ts_ip - changed_when: false - - - name: Stash as host fact - ansible.builtin.set_fact: - tailscale_ipv4: "{{ _ts_ip.stdout_lines[0] }}" - -# Phase 2 — install + configure + start. -- name: Install and configure etcd - hosts: pingarthur - become: true - vars_files: - - vault.yml - # serial=1: only one etcd member restarts at a time. Quorum stays at - # 2/3 throughout; the pingarthur app co-located with the restarting - # member will log.Fatalf on etcd unreachable and systemd will restart - # it once the local member is back — that's a per-node blip, never - # cluster-wide. - # - # For initial bootstrap, etcd handles this fine: each member starts - # with --initial-cluster listing all peers and blocks until quorum is - # reachable. systemctl start returns quickly (the process is up and - # waiting on peers), so Ansible advances to the next host without - # waiting for cluster health. All 3 land within ~30s, quorum forms. - serial: 1 - - vars: - etcd_version: "v3.6.10" # match go.mod's etcd client v3.6 line - etcd_user: etcd - etcd_data_dir: /var/lib/etcd - etcd_install_dir: /usr/local/bin - etcd_conf_dir: /etc/etcd - - tasks: - - name: Create etcd system user - ansible.builtin.user: - name: "{{ etcd_user }}" - system: true - shell: /usr/sbin/nologin - home: "{{ etcd_data_dir }}" - create_home: false - - - name: Create etcd data dir - ansible.builtin.file: - path: "{{ etcd_data_dir }}" - owner: "{{ etcd_user }}" - group: "{{ etcd_user }}" - mode: "0700" - state: directory - - - name: Create etcd config dir - ansible.builtin.file: - path: "{{ etcd_conf_dir }}" - mode: "0755" - state: directory - - # Version sniff — etcd --version prints "etcd Version: 3.6.10\n…". - # Skip download/install if the installed major.minor.patch matches. - - name: Check installed etcd version - ansible.builtin.command: "{{ etcd_install_dir }}/etcd --version" - register: _etcd_installed - changed_when: false - failed_when: false - - - name: Set etcd_needs_install fact - ansible.builtin.set_fact: - etcd_needs_install: "{{ etcd_version[1:] not in (_etcd_installed.stdout | default('')) }}" - - - name: Download etcd release tarball - ansible.builtin.get_url: - url: "https://github.com/etcd-io/etcd/releases/download/{{ etcd_version }}/etcd-{{ etcd_version }}-linux-arm64.tar.gz" - dest: "/tmp/etcd-{{ etcd_version }}-linux-arm64.tar.gz" - mode: "0644" - when: etcd_needs_install - - - name: Extract etcd binaries - ansible.builtin.unarchive: - src: "/tmp/etcd-{{ etcd_version }}-linux-arm64.tar.gz" - dest: /tmp - remote_src: true - creates: "/tmp/etcd-{{ etcd_version }}-linux-arm64/etcd" - when: etcd_needs_install - - - name: Install etcd + etcdctl binaries - ansible.builtin.copy: - src: "/tmp/etcd-{{ etcd_version }}-linux-arm64/{{ item }}" - dest: "{{ etcd_install_dir }}/{{ item }}" - mode: "0755" - remote_src: true - loop: - - etcd - - etcdctl - when: etcd_needs_install - notify: restart etcd - - - name: Render etcd config - ansible.builtin.template: - src: etcd.conf.j2 - dest: "{{ etcd_conf_dir }}/etcd.conf" - mode: "0644" - notify: restart etcd - - - name: Install etcd systemd unit - ansible.builtin.copy: - src: etcd.service - dest: /etc/systemd/system/etcd.service - mode: "0644" - notify: - - reload systemd - - restart etcd - - - name: Enable + start etcd - ansible.builtin.systemd: - name: etcd - enabled: true - state: started - - handlers: - - name: reload systemd - ansible.builtin.systemd: - daemon_reload: true - - - name: restart etcd - ansible.builtin.systemd: - name: etcd - state: restarted diff --git a/ansible/files/canary.service b/ansible/files/canary.service deleted file mode 100644 index 6a8f9c0..0000000 --- a/ansible/files/canary.service +++ /dev/null @@ -1,28 +0,0 @@ -[Unit] -Description=PingArthur Canary (deliberate status cycle) -Documentation=https://pingarthur.com -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -# DynamicUser provisions an ephemeral UID per start — no account setup -# required on the host (divepool edge doesn't have a canary user). -DynamicUser=yes -EnvironmentFile=/etc/canary/env -ExecStart=/usr/local/bin/canary -Restart=on-failure -RestartSec=5 - -# Hardening. Canary reads /etc/canary/env + binds a loopback port. -NoNewPrivileges=true -PrivateTmp=true -ProtectSystem=strict -ProtectHome=true -ProtectKernelTunables=true -ProtectKernelModules=true -ProtectControlGroups=true -RestrictNamespaces=true - -[Install] -WantedBy=multi-user.target diff --git a/ansible/files/etcd.service b/ansible/files/etcd.service deleted file mode 100644 index 6f28390..0000000 --- a/ansible/files/etcd.service +++ /dev/null @@ -1,30 +0,0 @@ -[Unit] -Description=etcd key-value store -Documentation=https://github.com/etcd-io/etcd -# Peer URLs bind to the Tailscale interface, so etcd must wait for tailscaled -# to bring it up. network-online covers the public NIC for the apt path on -# initial install; tailscaled covers the actual peer interface. -After=network-online.target tailscaled.service -Wants=network-online.target -Requires=tailscaled.service - -[Service] -Type=notify -User=etcd -Group=etcd -EnvironmentFile=/etc/etcd/etcd.conf -ExecStart=/usr/local/bin/etcd -Restart=on-failure -RestartSec=5 -LimitNOFILE=65536 - -# systemd-level hardening. ReadWritePaths whitelists the data dir so -# ProtectSystem=strict doesn't lock etcd out of its own storage. -PrivateTmp=true -ProtectSystem=strict -ProtectHome=true -NoNewPrivileges=true -ReadWritePaths=/var/lib/etcd - -[Install] -WantedBy=multi-user.target diff --git a/ansible/files/nftables.conf b/ansible/files/nftables.conf deleted file mode 100644 index 380e795..0000000 --- a/ansible/files/nftables.conf +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/sbin/nft -f -# PingArthur host firewall. Default-deny on the public interface, permissive -# on tailscale0 (mesh is the trust boundary). -# -# Public interface (eth0 / ens3 / whatever Hetzner assigns): -# 22/tcp SSH -# 80/tcp HTTP (Caddy, redirects to 443) -# 443/tcp HTTPS (Caddy, reverse-proxies to pingarthur:8080 on localhost) -# -# tailscale0: everything accepted. Peer traffic flowing here: -# 2379-2380/tcp etcd (peer + client across nodes) -# 9090/tcp pingarthur's /metrics listener (Prometheus scrape) -# App ↔ local etcd stays on 127.0.0.1 and never hits nftables. -# -# Outbound: accepted (checker HEADs + apt + tailscale control plane all need it). - -flush ruleset - -table inet filter { - chain input { - type filter hook input priority filter; policy drop; - - ct state established,related accept - ct state invalid drop - - iifname "lo" accept - iifname "tailscale0" accept - - ip protocol icmp icmp type { echo-request, destination-unreachable, time-exceeded, parameter-problem } accept - - tcp dport { 22, 80, 443 } accept - } - - chain forward { - type filter hook forward priority filter; policy drop; - } - - chain output { - type filter hook output priority filter; policy accept; - } -} diff --git a/ansible/files/pingarthur.service b/ansible/files/pingarthur.service deleted file mode 100644 index 928e4af..0000000 --- a/ansible/files/pingarthur.service +++ /dev/null @@ -1,36 +0,0 @@ -[Unit] -Description=PingArthur uptime monitor -Documentation=https://pingarthur.com -# Wait for etcd — the app probes it on startup and log.Fatalf's if it -# can't reach the local member. -After=network-online.target etcd.service -Wants=network-online.target -Requires=etcd.service - -[Service] -Type=simple -User=pingarthur -Group=pingarthur -EnvironmentFile=/etc/pingarthur/env -ExecStart=/usr/local/bin/pingarthur -Restart=on-failure -RestartSec=5 - -# Supervision: the app log.Fatalf's on repeated etcd failures; give -# systemd a burst limit so a hard outage doesn't loop-restart forever. -StartLimitIntervalSec=600 -StartLimitBurst=10 - -# systemd-level hardening. The app only needs to read /etc/pingarthur/env -# and execute itself — no disk writes anywhere. -NoNewPrivileges=true -PrivateTmp=true -ProtectSystem=strict -ProtectHome=true -ProtectKernelTunables=true -ProtectKernelModules=true -ProtectControlGroups=true -RestrictNamespaces=true - -[Install] -WantedBy=multi-user.target diff --git a/ansible/files/sshd_hardening.conf b/ansible/files/sshd_hardening.conf deleted file mode 100644 index 4796679..0000000 --- a/ansible/files/sshd_hardening.conf +++ /dev/null @@ -1,7 +0,0 @@ -# Drop-in loaded by Ubuntu's default `Include /etc/ssh/sshd_config.d/*.conf`. -# Settings here override whatever's in /etc/ssh/sshd_config, so we don't -# have to edit the main file. - -PermitRootLogin no -PasswordAuthentication no -KbdInteractiveAuthentication no diff --git a/ansible/group_vars/all/meta_monitors.example.yml b/ansible/group_vars/all/meta_monitors.example.yml deleted file mode 100644 index 61de1df..0000000 --- a/ansible/group_vars/all/meta_monitors.example.yml +++ /dev/null @@ -1,34 +0,0 @@ ---- -# Plaintext on purpose. None of these values is a long-term secret: -# -# - `app_password` is an ATProto app password for a DEDICATED bsky account -# used only as a status post channel. The blast radius if leaked is -# defacement of that one account; we don't link it to any other identity. -# Treated like any normal user supplying it through `POST /api/v1/monitors`. -# - URLs are public. -# - `interval_seconds` is operational, not sensitive. -# -# bind_meta.yml mints fresh `pa_` bearers each run, pushes them to the -# canary as echo tokens, then binds via the public API. The minted bearers -# are recorded back into `echo_tokens.yml` (also plaintext) for traceability; -# they aren't needed again — once bound, the canary just keeps echoing them. -# -# Re-running the play creates fresh monitors (new tokHashes); the previous -# monitors auto-stop after 24 h once their tokens stop being echoed. -# -# Copy this file to `meta_monitors.yml` (gitignored) and fill in real -# values before running bind_meta.yml. - -meta_monitors: - - name: canary - url: https://canary.YOUR_DOMAIN/ - interval_seconds: 60 - # No atmosphere — the canary cycles state every minute by design; - # posting that to bsky would be pure noise. - - - name: sync_check - url: https://canary.YOUR_DOMAIN/sync-check - interval_seconds: 60 - atmosphere: - handle: status.YOUR_DOMAIN - app_password: "REPLACE_ME" diff --git a/ansible/group_vars/all/vars.yml b/ansible/group_vars/all/vars.yml deleted file mode 100644 index 811ca3a..0000000 --- a/ansible/group_vars/all/vars.yml +++ /dev/null @@ -1,36 +0,0 @@ ---- -# Path to the admin's SSH pubkey on the controller (this machine). That key -# ends up in /home/pingarthur/.ssh/authorized_keys on all 3 nodes. Override -# per-user by setting admin_pubkey_path in inventory or -e on the CLI. -admin_pubkey_path: "~/.ssh/id_ed25519.pub" - -# Vaulted secrets are referenced via plain aliases so playbooks can use the -# unprefixed names without coupling task code to vault internals. Playbooks -# that use these must load them explicitly via `vars_files: [vault.yml]` -# (the vault file lives at ansible/vault.yml, NOT under group_vars/all/ — -# that's deliberate, keeps canary.yml vault-free). Canary's echo tokens -# come from group_vars/all/echo_tokens.yml (plaintext, see comment there). -tailscale_auth_key: "{{ vault_tailscale_auth_key }}" -etcd_cluster_token: "{{ vault_etcd_cluster_token }}" -token_hmac_secret: "{{ vault_token_hmac_secret }}" -atmosphere_enc_key: "{{ vault_atmosphere_enc_key }}" -uniteddomains_api_key: "{{ vault_uniteddomains_api_key }}" - -# Where the canary's /sync-check endpoint queries for pingarthur's view -# of the world. Public URL because the canary runs on divepool edge and -# auths against /api/v1/status using the canary-echo-token bearer. -pingarthur_base_url: "https://{{ pingarthur_domain }}" - -# Email address registered with Let's Encrypt when lego creates the ACME -# account. Expiry warnings go here. Used by ansible/tls.yml. -acme_email: jasper@raedisch.net - -# Public domain this cluster serves. Caddy uses this for its TLS site -# block and automatic Let's Encrypt issuance. -pingarthur_domain: pingarthur.com - -# Source URL shown in the terse landing response for all non-API paths. -pingarthur_source_url: "https://tangled.sh/@divepool.social/pingarthur" - -# Global cap on bound monitors (matches MAX_MONITORS in .env.example). -max_monitors: 10000 diff --git a/ansible/inventory.example.yml b/ansible/inventory.example.yml deleted file mode 100644 index b96333d..0000000 --- a/ansible/inventory.example.yml +++ /dev/null @@ -1,35 +0,0 @@ ---- -# Template — copy to inventory.yml (gitignored) and fill in real IPs: -# cp inventory.example.yml inventory.yml -# -# 3 Hetzner CAX11s — one per zone (Falkenstein / Nuremberg / Helsinki). -# Boxes are provisioned by hand; ansible starts from "I have 3 IPs with -# passwordless root SSH". Replace the placeholder IPs with the real ones -# once the servers exist. -all: - children: - pingarthur: - hosts: - pa-fsn1: - ansible_host: REPLACE_ME_FALKENSTEIN_IP - pa-nbg1: - ansible_host: REPLACE_ME_NUREMBERG_IP - pa-hel1: - ansible_host: REPLACE_ME_HELSINKI_IP - vars: - # Pre-bootstrap, ansible connects as root. Post-bootstrap, the same - # run also installs pingarthur's admin pubkey and disables root SSH, - # so subsequent invocations must override ansible_user=pingarthur. - ansible_user: root - ansible_python_interpreter: /usr/bin/python3 - - # Canary lives on a separate host outside the pingarthur failure domain - # (divepool edge). Caddy + TLS + DNS are managed there by the divepool - # project; canary.yml here only installs the binary + systemd unit. - canary: - hosts: - divepool-edge: - ansible_host: REPLACE_ME_DIVEPOOL_EDGE_IP - vars: - ansible_user: divepool - ansible_python_interpreter: /usr/bin/python3 diff --git a/ansible/templates/Caddyfile.j2 b/ansible/templates/Caddyfile.j2 deleted file mode 100644 index 3b57560..0000000 --- a/ansible/templates/Caddyfile.j2 +++ /dev/null @@ -1,56 +0,0 @@ -# Rendered by ansible/deploy.yml. Caddy terminates TLS for the public -# domain and reverse-proxies only the documented API surface to the app -# on 127.0.0.1:8080. /metrics is deliberately NOT proxied — it's an -# internal scrape target, reachable only on loopback or via Tailscale. -# -# Static cert + key come from ansible/tls.yml, which runs `lego` with -# DNS-01 against united-domains from the controller and copies cert files -# into /etc/caddy/certs/ on each node. Caddy's auto-HTTPS is therefore -# NOT in play — multi-node ACME would race (each LE secondary validator -# lands on a different A record; node B serves node A's challenge). The -# tls directive with explicit file paths tells Caddy to skip ACME. - -{{ pingarthur_domain }} { - tls /etc/caddy/certs/{{ pingarthur_domain }}.crt /etc/caddy/certs/{{ pingarthur_domain }}.key - - # Security headers — matches divepool.social posture. HSTS preload-eligible, - # no framing, no MIME sniffing, conservative referrer policy, no ambient - # access to camera/mic/geo (defense-in-depth even though pingarthur has no - # UI). -Server hides Caddy's banner. - header { - Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" - X-Frame-Options "DENY" - X-Content-Type-Options "nosniff" - Referrer-Policy "strict-origin-when-cross-origin" - Permissions-Policy "camera=(), microphone=(), geolocation=()" - -Server - } - - # Public API endpoints + supervisor health probe + homepage. - handle /api/v1/* { - reverse_proxy 127.0.0.1:8080 - } - handle /status { - reverse_proxy 127.0.0.1:8080 - } - # SEO/discoverability surfaces served by the Go binary — see seo.go. - handle /robots.txt { - reverse_proxy 127.0.0.1:8080 - } - handle /sitemap.xml { - reverse_proxy 127.0.0.1:8080 - } - handle /llms.txt { - reverse_proxy 127.0.0.1:8080 - } - handle / { - reverse_proxy 127.0.0.1:8080 - } - - # Anything else 404s. Keeps /metrics and any other non-public path off - # the internet by default; adding a new public route means adding a - # `handle` block above. - handle { - respond 404 - } -} diff --git a/ansible/templates/canary.env.j2 b/ansible/templates/canary.env.j2 deleted file mode 100644 index f35c4e4..0000000 --- a/ansible/templates/canary.env.j2 +++ /dev/null @@ -1,17 +0,0 @@ -# Rendered by ansible/canary.yml. Tokens come from ansible-vault. -# Both must be pa_ bearers that were bound into pingarthur; the checker -# validates HMAC on each echo and matches the hash against the monitor -# identity, so the wrong token reads as a token_mismatch. -CANARY_ECHO_TOKEN={{ canary_echo_token }} - -# Echoed by GET /sync-check so a second pingarthur monitor can track -# whether pingarthur's own observation of the canary is in sync. Leave -# blank to disable the sync-check endpoint (falls through to 404). -SYNC_CHECK_ECHO_TOKEN={{ sync_check_echo_token | default('') }} - -# Where sync-check queries for pingarthur's observation of the canary. -# Must reach pingarthur's public API (the canary's Bearer auth is the -# canary-echo-token, which only works against the public endpoint). -PINGARTHUR_BASE_URL={{ pingarthur_base_url | default('https://pingarthur.com') }} - -LISTEN_ADDR=127.0.0.1:8091 diff --git a/ansible/templates/etcd.conf.j2 b/ansible/templates/etcd.conf.j2 deleted file mode 100644 index 34b5a02..0000000 --- a/ansible/templates/etcd.conf.j2 +++ /dev/null @@ -1,24 +0,0 @@ -# Rendered by ansible/etcd.yml. All values are env-var form; etcd reads them -# via EnvironmentFile= in etcd.service. -# -# Peer URLs bind to this node's Tailscale IP only — peer traffic encrypted -# by WireGuard at the network layer. Client URLs bind to 127.0.0.1 only; -# the app talks to its local member over loopback and never crosses a NIC. - -ETCD_NAME={{ inventory_hostname }} -ETCD_DATA_DIR={{ etcd_data_dir }} - -ETCD_LISTEN_PEER_URLS=http://{{ tailscale_ipv4 }}:2380 -ETCD_INITIAL_ADVERTISE_PEER_URLS=http://{{ tailscale_ipv4 }}:2380 - -ETCD_LISTEN_CLIENT_URLS=http://127.0.0.1:2379 -ETCD_ADVERTISE_CLIENT_URLS=http://127.0.0.1:2379 - -ETCD_INITIAL_CLUSTER={% for host in groups['pingarthur'] %}{{ host }}=http://{{ hostvars[host].tailscale_ipv4 }}:2380{% if not loop.last %},{% endif %}{% endfor %} - -# Bootstrap-only flags. After the data dir is initialized, etcd persists -# cluster membership in WAL and ignores these. Leaving state=new makes a -# fresh-data-dir restart re-bootstrap; to add/remove members later, use -# `etcdctl member add/remove` against the running cluster. -ETCD_INITIAL_CLUSTER_STATE=new -ETCD_INITIAL_CLUSTER_TOKEN={{ etcd_cluster_token }} diff --git a/ansible/templates/pingarthur.env.j2 b/ansible/templates/pingarthur.env.j2 deleted file mode 100644 index 19ef850..0000000 --- a/ansible/templates/pingarthur.env.j2 +++ /dev/null @@ -1,11 +0,0 @@ -# Rendered by ansible/deploy.yml. Secrets come from ansible-vault -# (group_vars/all/vault.yml). -TOKEN_HMAC_SECRET={{ token_hmac_secret }} -ATMOSPHERE_ENC_KEY={{ atmosphere_enc_key }} -LISTEN_ADDR=127.0.0.1:8080 -ETCD_ENDPOINTS=127.0.0.1:2379 -MAX_MONITORS={{ max_monitors }} -# Metrics listener binds on all interfaces; nftables blocks public NIC -# access and leaves it open on tailscale0, so effective reach is -# tailnet peers only (Prometheus scraper). Set empty to disable. -METRICS_ADDR=:9090 diff --git a/ansible/tls.yml b/ansible/tls.yml deleted file mode 100644 index b3bcf6d..0000000 --- a/ansible/tls.yml +++ /dev/null @@ -1,132 +0,0 @@ ---- -# Obtains the pingarthur.com TLS cert via lego + DNS-01 (united-domains) -# on the controller, then distributes cert + key to all 3 nodes. Caddy on -# each node reads the static cert — no ACME auto-issuance, which would -# race across nodes under DNS round-robin. -# -# Run: -# ansible-playbook tls.yml --ask-vault-pass -# -# Idempotent: lego's `run` command creates a cert if none exists, -# otherwise it renews only if inside the renewal window (~30 days before -# expiry). Safe to re-run daily via cron. - -# === Phase 1 — obtain/renew on the controller ============================= -- name: Obtain or renew TLS cert via lego (DNS-01, united-domains) - hosts: localhost - connection: local - gather_facts: false - - vars_files: - - vault.yml - - vars: - lego_home: "{{ lookup('env', 'HOME') }}/.lego-pingarthur" - cert_domain: "{{ pingarthur_domain }}" - - tasks: - - name: Ensure lego home dir exists - ansible.builtin.file: - path: "{{ lego_home }}" - state: directory - mode: "0700" - - - name: Check whether a cert already exists - ansible.builtin.stat: - path: "{{ lego_home }}/certificates/{{ cert_domain }}.crt" - register: existing_cert - - - name: Run lego (initial issuance) - ansible.builtin.command: - cmd: >- - lego - --accept-tos - --email={{ acme_email }} - --path={{ lego_home }} - --dns=uniteddomains - --domains={{ cert_domain }} - run - environment: - UNITEDDOMAINS_API_KEY: "{{ uniteddomains_api_key }}" - when: not existing_cert.stat.exists - no_log: true - - - name: Run lego (renewal) - ansible.builtin.command: - cmd: >- - lego - --accept-tos - --email={{ acme_email }} - --path={{ lego_home }} - --dns=uniteddomains - --domains={{ cert_domain }} - renew - --days=30 - environment: - UNITEDDOMAINS_API_KEY: "{{ uniteddomains_api_key }}" - when: existing_cert.stat.exists - register: renewal - changed_when: "'no renewal' not in renewal.stderr | default('')" - no_log: true - - - name: Stash cert + key paths as facts for phase 2 - ansible.builtin.set_fact: - local_cert_path: "{{ lego_home }}/certificates/{{ cert_domain }}.crt" - local_key_path: "{{ lego_home }}/certificates/{{ cert_domain }}.key" - -# === Phase 2 — distribute to all 3 nodes =================================== -- name: Ship cert + key to every node, reload Caddy - hosts: pingarthur - become: true - # Rolling cert rotation — one node's Caddy reloads at a time. A Caddy - # reload is essentially instant, so the "all simultaneous" case is - # fine too, but serial=1 keeps rollouts consistent across playbooks - # and makes each reload separately observable if anything goes wrong. - serial: 1 - - vars: - cert_domain: "{{ pingarthur_domain }}" - cert_dir: /etc/caddy/certs - # Re-derive paths — hostvars['localhost'] doesn't flow unless we do this. - local_cert_path: "{{ lookup('env', 'HOME') }}/.lego-pingarthur/certificates/{{ cert_domain }}.crt" - local_key_path: "{{ lookup('env', 'HOME') }}/.lego-pingarthur/certificates/{{ cert_domain }}.key" - - tasks: - - name: Ensure cert dir exists - ansible.builtin.file: - path: "{{ cert_dir }}" - state: directory - owner: root - group: caddy - mode: "0750" - - - name: Upload cert (fullchain) - ansible.builtin.copy: - src: "{{ local_cert_path }}" - dest: "{{ cert_dir }}/{{ cert_domain }}.crt" - owner: root - group: caddy - mode: "0644" - notify: reload caddy - - - name: Upload private key - ansible.builtin.copy: - src: "{{ local_key_path }}" - dest: "{{ cert_dir }}/{{ cert_domain }}.key" - owner: root - group: caddy - mode: "0640" - notify: reload caddy - no_log: true - - - name: Ensure Caddy is enabled + started - ansible.builtin.systemd: - name: caddy - enabled: true - state: started - - handlers: - - name: reload caddy - ansible.builtin.systemd: - name: caddy - state: reloaded diff --git a/ansible/vault.yml.example b/ansible/vault.yml.example deleted file mode 100644 index 95a9a67..0000000 --- a/ansible/vault.yml.example +++ /dev/null @@ -1,55 +0,0 @@ ---- -# Placeholder for the ansible-vault-encrypted secret store. Copy this to -# vault.yml (at ansible/vault.yml, NOT under group_vars/all/) and encrypt it: -# -# cp vault.yml.example vault.yml -# ansible-vault encrypt vault.yml -# -# This file is loaded only by plays that include it explicitly via -# vars_files: -# - vault.yml -# (bootstrap, etcd, deploy, tls). canary.yml deliberately doesn't — -# rotating the canary's echo tokens shouldn't require --ask-vault-pass. -# Those tokens live plaintext in ansible/group_vars/all/echo_tokens.yml. -# -# Generate an ephemeral Tailscale auth key at -# https://login.tailscale.com/admin/settings/keys (Reusable + Ephemeral + -# Pre-authorized). "Ephemeral" means Tailscale removes the node from the -# tailnet shortly after it disappears — right behavior for cloud hosts. - -vault_tailscale_auth_key: "tskey-auth-REPLACE_ME" - -# etcd's cluster token is an anti-collision string burned into each member's -# WAL during initial bootstrap. Not used to authenticate clients (etcd -# auth is a separate concern, not used in v0). Generate with: -# openssl rand -hex 16 -vault_etcd_cluster_token: "REPLACE_ME_RANDOM_HEX" - -# HMAC secret for signing pa_<...> bearer tokens. Rotating this invalidates -# every outstanding unbound token AND breaks every bound monitor's checker -# (the echoed token no longer passes verifyTokenSignature). Treat as a -# once-forever value. Generate with: -# openssl rand -hex 32 -vault_token_hmac_secret: "REPLACE_ME_RANDOM_HEX" - -# AES-256-GCM key encrypting Atmosphere app passwords at rest. Exactly -# 32 bytes (64 hex chars). Rotating/losing this renders every monitor's -# stored Atmosphere credential unrecoverable — users would have to -# delete + re-bind. Treat as a once-forever value. Protects against -# etcd-data-at-rest leaks (backups, peer-traffic misconfig); full-box -# compromise defeats it because the key and ciphertext co-reside. -# Generate with: -# openssl rand -hex 32 -vault_atmosphere_enc_key: "REPLACE_ME_RANDOM_HEX" - -# United-domains DNS API key for lego's DNS-01 challenge. Format is -# "." — generated in united-domains admin area, shown -# once at creation. NOTE: this key grants admin over EVERY domain in -# your united-domains account (no per-zone scoping). Protect accordingly. -vault_uniteddomains_api_key: "REPLACE_ME_PREFIX.REPLACE_ME_SECRET" - -# The two canary echo tokens (CANARY_ECHO_TOKEN and SYNC_CHECK_ECHO_TOKEN) -# used to live here. They were moved to ansible/group_vars/all/echo_tokens.yml -# because the canary service broadcasts them in plaintext response headers -# on every HEAD — they aren't secrets, and keeping them vault-encrypted -# meant canary.yml couldn't be re-run without --ask-vault-pass. diff --git a/assets/og-image.jpg b/assets/og-image.jpg deleted file mode 100644 index 2ef2a245377f1161fa9b7c3cc9e28dc64d81a730..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 34810 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 atmosphereHandleMax || !handleRE.MatchString(handle) { - return nil, fmt.Errorf("atmosphere: invalid handle") - } - if appPassword == "" || len(appPassword) > atmosphereAppPasswordMax { - return nil, fmt.Errorf("atmosphere: invalid app_password") - } - - did, err := atprotoResolveHandle(ctx, client, handle) - if err != nil { - return nil, fmt.Errorf("atmosphere: resolve handle: %w", err) - } - pds, err := atprotoResolvePDS(ctx, client, did) - if err != nil { - return nil, fmt.Errorf("atmosphere: resolve pds: %w", err) - } - empty, err := atprotoRepoEmpty(ctx, client, pds, did) - if err != nil { - return nil, fmt.Errorf("atmosphere: list records: %w", err) - } - if !empty { - return nil, fmt.Errorf("atmosphere: account has existing posts; create a dedicated account") - } - - pwBytes := []byte(appPassword) - defer zeroBytes(pwBytes) - if _, err := atprotoCreateSession(ctx, client, pds, did, pwBytes); err != nil { - return nil, fmt.Errorf("atmosphere: verify app_password: %w", err) - } - sealed, err := enc.seal(pwBytes) - if err != nil { - return nil, fmt.Errorf("atmosphere: seal: %w", err) - } - - return &atmosphereBinding{ - Handle: handle, - DID: did, - PDSURL: pds, - EncryptedPassword: sealed, - }, nil -} - -// doAtmospherePost re-reads the monitor blob (to pick up the encrypted -// password), authenticates against the user's PDS, posts the status change, -// and CAS-updates LastPostAt / LastPostError back on the blob. Called from -// a detached goroutine — ctx is the long-lived signal context so a clean -// shutdown cancels in-flight posts. -func doAtmospherePost(ctx context.Context, etcd *clientv3.Client, enc *atmosphereEncryptor, client *http.Client, tokHash string, obs observation) { - postCtx, cancel := context.WithTimeout(ctx, 20*time.Second) - defer cancel() - - blobResp, err := etcd.Get(postCtx, monitorKey(tokHash)) - if err != nil { - if ctx.Err() != nil { - return - } - log.Fatalf("atmosphere get blob: %v", err) - } - if blobResp.Count == 0 { - return // monitor vanished (auto-stop, delete, race) - } - var blob monitorBlob - if err := json.Unmarshal(blobResp.Kvs[0].Value, &blob); err != nil { - log.Fatalf("atmosphere unmarshal blob: %v", err) - } - if blob.Atmosphere == nil { - return // atmosphere removed since transition was detected - } - - pw, err := enc.open(blob.Atmosphere.EncryptedPassword) - if err != nil { - metricAtmospherePostTotal.WithLabelValues("decrypt_failed").Inc() - recordAtmosphereError(ctx, etcd, tokHash, fmt.Sprintf("decrypt: %v", err)) - return - } - defer zeroBytes(pw) - - accessJwt, err := atprotoCreateSession(postCtx, client, blob.Atmosphere.PDSURL, blob.Atmosphere.DID, pw) - if err != nil { - metricAtmospherePostTotal.WithLabelValues("auth_failed").Inc() - recordAtmosphereError(ctx, etcd, tokHash, fmt.Sprintf("auth: %v", err)) - return - } - - text := formatPost(obs) - if err := atprotoCreatePost(postCtx, client, blob.Atmosphere.PDSURL, blob.Atmosphere.DID, accessJwt, text, tokHash); err != nil { - metricAtmospherePostTotal.WithLabelValues("post_failed").Inc() - recordAtmosphereError(ctx, etcd, tokHash, fmt.Sprintf("post: %v", err)) - return - } - metricAtmospherePostTotal.WithLabelValues("ok").Inc() - metricAtmospherePostLastSuccess.SetToCurrentTime() - recordAtmosphereSuccess(ctx, etcd, tokHash, obsSig(obs)) -} - -// recordAtmosphereSuccess / recordAtmosphereError write back onto the blob -// with a best-effort CAS loop. Concurrent checker writes can cause the CAS -// to fail — we drop the update in that case; the next transition overwrites -// anyway and metrics are authoritative for success/failure rate. -func recordAtmosphereSuccess(ctx context.Context, etcd *clientv3.Client, tokHash, sig string) { - mutateAtmosphere(ctx, etcd, tokHash, func(a *atmosphereBinding) { - a.LastPostAt = time.Now().UTC() - a.LastPostError = "" - a.LastPostErrorAt = time.Time{} - // Advance only on success: a failed post leaves the sig behind, so the - // next check sees current != last-posted and retries until it lands. - a.LastPostedSig = sig - a.PostFailCount = 0 - }) -} - -func recordAtmosphereError(ctx context.Context, etcd *clientv3.Client, tokHash, msg string) { - if len(msg) > 200 { - msg = msg[:200] - } - mutateAtmosphere(ctx, etcd, tokHash, func(a *atmosphereBinding) { - a.LastPostError = msg - a.LastPostErrorAt = time.Now().UTC() - a.PostFailCount++ - }) -} - -func mutateAtmosphere(ctx context.Context, etcd *clientv3.Client, tokHash string, fn func(*atmosphereBinding)) { - blobResp, err := etcd.Get(ctx, monitorKey(tokHash)) - if err != nil { - if ctx.Err() != nil { - return - } - log.Fatalf("atmosphere mutate get: %v", err) - } - if blobResp.Count == 0 { - return - } - var blob monitorBlob - if err := json.Unmarshal(blobResp.Kvs[0].Value, &blob); err != nil { - log.Fatalf("atmosphere mutate unmarshal: %v", err) - } - if blob.Atmosphere == nil { - return - } - fn(blob.Atmosphere) - bytes, err := json.Marshal(blob) - if err != nil { - log.Fatalf("atmosphere mutate marshal: %v", err) - } - _, err = etcd.Txn(ctx). - If(clientv3.Compare(clientv3.ModRevision(monitorKey(tokHash)), "=", blobResp.Kvs[0].ModRevision)). - Then(clientv3.OpPut(monitorKey(tokHash), string(bytes))). - Commit() - if err != nil { - if ctx.Err() != nil { - return - } - log.Fatalf("atmosphere mutate commit: %v", err) - } -} - -// formatPost returns the post body for an observation change. -// -// Body precedence: -// 1. obs.HealthStatus verbatim — fully customizable by the target, -// already rune-capped on ingest by verify.go's healthStatusMax. -// 2. Descriptive fallback when the header is absent. Bluesky requires -// non-empty post text; we use the room (≤ 280 runes / ≤ 300 graphemes) -// to give the reader enough to either fix their config or know to -// wait out a transient failure. Each fallback names what failed and -// whether it's typically user-fixable, persistent, or transient. -// -// The final truncate is a guardrail; under normal flow we stay well under. -func formatPost(obs observation) string { - text := fallbackPostText(obs) - if obs.HealthStatus != "" { - text = obs.HealthStatus - } - if runeLen(text) > atmospherePostTextMax { - text = truncateRunes(text, atmospherePostTextMax-1) + "…" - } - return text -} - -// fallbackPostText produces the post body when the target didn't supply -// an X-Health-Status header. Strings are stable — embedded in posts and -// indexed by Bluesky's tag/text search — so don't reword casually. -func fallbackPostText(obs observation) string { - switch obs.Error { - case errCatTokenMismatch: - return "X-PingArthur-Token echo missing or wrong. Have every HEAD response on the URL return the bearer verbatim. 24h grace before auto-stop." - case errCatBlocked: - return "Target IP is in PingArthur's SSRF blocklist (private/loopback/CGNAT/multicast). Repoint the URL at a public address." - case errCatDNS: - return "DNS lookup failed (NXDOMAIN, no IPv4 record, or resolver error). Usually persistent if the host is misconfigured; can be transient on resolver outages." - case errCatTLS: - return "TLS handshake failed (bad cert, hostname mismatch, or protocol error). Persistent until cert/config is fixed." - case errCatTimeout: - return "Probe timed out (>10 s). Could be a slow target, an overloaded server, or transient network weather — re-checks every 60 min." - case errCatRefused: - return "TCP connection refused. Target service is down, the port is wrong, or a firewall is blocking us." - case errCatOther: - return "Probe failed (oversized response headers, malformed HTTP, or another transport-layer issue). See PingArthur openapi.json → Observation.error for the full classification." - } - if obs.HTTPStatus > 0 { - return fmt.Sprintf("HTTP %d — target returned a response but no X-Health-Status header. Set one to customize this post.", obs.HTTPStatus) - } - return "No signal recorded yet." -} - -func runeLen(s string) int { return len([]rune(s)) } - -func truncateRunes(s string, n int) string { - if runeLen(s) <= n { - return s - } - r := []rune(s) - return string(r[:n]) -} - -func zeroBytes(b []byte) { - for i := range b { - b[i] = 0 - } -} - -// ─────────────────── low-level atproto HTTP helpers ──────────────────── - -type resolveHandleResp struct { - DID string `json:"did"` -} - -func atprotoResolveHandle(ctx context.Context, client *http.Client, handle string) (string, error) { - url := atprotoResolveHandleURL + "?handle=" + handle - var body resolveHandleResp - if err := atprotoGetJSON(ctx, client, url, "", &body); err != nil { - return "", err - } - if body.DID == "" { - return "", errors.New("empty did in response") - } - return body.DID, nil -} - -type didDoc struct { - Service []didService `json:"service"` -} - -type didService struct { - ID string `json:"id"` - Type string `json:"type"` - ServiceEndpoint string `json:"serviceEndpoint"` -} - -// atprotoResolvePDS fetches the DID doc and returns the atproto_pds endpoint. -// did:plc:* is resolved via plc.directory; did:web: is resolved via -// `https:///.well-known/did.json` (self-hosted PDSes). -func atprotoResolvePDS(ctx context.Context, client *http.Client, did string) (string, error) { - var docURL string - switch { - case strings.HasPrefix(did, "did:plc:"): - docURL = plcDirectoryBase + did - case strings.HasPrefix(did, "did:web:"): - host := strings.TrimPrefix(did, "did:web:") - // did:web hosts may encode port as "host%3A1234" — unescape for URL. - host = strings.ReplaceAll(host, "%3A", ":") - docURL = "https://" + host + "/.well-known/did.json" - default: - return "", fmt.Errorf("unsupported did method: %s", did) - } - var doc didDoc - if err := atprotoGetJSON(ctx, client, docURL, "", &doc); err != nil { - return "", err - } - for _, s := range doc.Service { - if s.Type == "AtprotoPersonalDataServer" && s.ServiceEndpoint != "" { - return strings.TrimRight(s.ServiceEndpoint, "/"), nil - } - } - return "", errors.New("no AtprotoPersonalDataServer service in did doc") -} - -type listRecordsResp struct { - Records []any `json:"records"` -} - -func atprotoRepoEmpty(ctx context.Context, client *http.Client, pds, did string) (bool, error) { - url := pds + "/xrpc/com.atproto.repo.listRecords?repo=" + did + "&collection=app.bsky.feed.post&limit=1" - var body listRecordsResp - if err := atprotoGetJSON(ctx, client, url, "", &body); err != nil { - return false, err - } - return len(body.Records) == 0, nil -} - -type createSessionReq struct { - Identifier string `json:"identifier"` - Password string `json:"password"` -} - -type createSessionResp struct { - AccessJwt string `json:"accessJwt"` -} - -// atprotoCreateSession verifies the app password works and returns an access -// JWT for subsequent createRecord calls. Takes the password as []byte so the -// caller can zero it after. -func atprotoCreateSession(ctx context.Context, client *http.Client, pds, did string, appPassword []byte) (string, error) { - reqBody := createSessionReq{Identifier: did, Password: string(appPassword)} - raw, err := json.Marshal(reqBody) - if err != nil { - return "", err - } - // Overwrite the password field in the JSON buffer post-send is not - // practical (HTTP library may retain buffers); best-effort: limit the - // password's lifetime to this function. - var body createSessionResp - if err := atprotoPostJSON(ctx, client, pds+"/xrpc/com.atproto.server.createSession", "", raw, &body); err != nil { - return "", err - } - if body.AccessJwt == "" { - return "", errors.New("empty accessJwt in createSession response") - } - return body.AccessJwt, nil -} - -type createRecordReq struct { - Repo string `json:"repo"` - Collection string `json:"collection"` - Record map[string]any `json:"record"` -} - -// atprotoCreatePost posts to the user's repo with two hidden tags: -// "PingArthur" (so all posts from this service are filterable) and the -// monitor's tokHash (so per-monitor posts are filterable without leaking -// the bearer — the hash is irreversible and is already what etcd keys on). -// Tags ride on app.bsky.feed.post.tags (lexicon: maxLength 8 entries, each -// ≤ 64 graphemes / 640 bytes), treated as metadata by Bluesky clients -// rather than rendered inline in post text. -func atprotoCreatePost(ctx context.Context, client *http.Client, pds, did, accessJwt, text, tokHash string) error { - reqBody := createRecordReq{ - Repo: did, - Collection: "app.bsky.feed.post", - Record: map[string]any{ - "$type": "app.bsky.feed.post", - "text": text, - "createdAt": time.Now().UTC().Format(time.RFC3339), - "tags": []string{"PingArthur", tokHash}, - }, - } - raw, err := json.Marshal(reqBody) - if err != nil { - return err - } - var ignored map[string]any - return atprotoPostJSON(ctx, client, pds+"/xrpc/com.atproto.repo.createRecord", accessJwt, raw, &ignored) -} - -// atprotoGetJSON / atprotoPostJSON are thin HTTP helpers that enforce a -// small response body cap (1 MiB) and surface non-2xx as errors with body -// prefix for diagnostics. bearer may be empty for anonymous calls. -func atprotoGetJSON(ctx context.Context, client *http.Client, url, bearer string, out any) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return err - } - if bearer != "" { - req.Header.Set("Authorization", "Bearer "+bearer) - } - return atprotoDo(client, req, out) -} - -func atprotoPostJSON(ctx context.Context, client *http.Client, url, bearer string, body []byte, out any) error { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - if bearer != "" { - req.Header.Set("Authorization", "Bearer "+bearer) - } - return atprotoDo(client, req, out) -} - -const atprotoMaxBody = 1 << 20 - -func atprotoDo(client *http.Client, req *http.Request, out any) error { - req.Header.Set("User-Agent", userAgent) - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - body, err := io.ReadAll(io.LimitReader(resp.Body, atprotoMaxBody)) - if err != nil { - return err - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - snippet := string(body) - if len(snippet) > 200 { - snippet = snippet[:200] - } - return fmt.Errorf("status %d: %s", resp.StatusCode, snippet) - } - if out != nil { - return json.Unmarshal(body, out) - } - return nil -} diff --git a/atmosphere_test.go b/atmosphere_test.go deleted file mode 100644 index 4a4efa0..0000000 --- a/atmosphere_test.go +++ /dev/null @@ -1,338 +0,0 @@ -package main - -import ( - "bytes" - "context" - "crypto/rand" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" -) - -// mustAtmoKey builds a random 32-byte AES-256 key for testing. -func mustAtmoKey(t *testing.T) []byte { - t.Helper() - b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { - t.Fatalf("rand.Read: %v", err) - } - return b -} - -func TestAtmosphereEncryptor_RoundTrip(t *testing.T) { - enc, err := newAtmosphereEncryptor(mustAtmoKey(t)) - if err != nil { - t.Fatalf("newAtmosphereEncryptor: %v", err) - } - plaintext := []byte("bsky-app-pass-1234-5678") - sealed, err := enc.seal(plaintext) - if err != nil { - t.Fatalf("seal: %v", err) - } - // base64 output is not the plaintext. - if strings.Contains(sealed, string(plaintext)) { - t.Fatal("sealed output contains plaintext") - } - opened, err := enc.open(sealed) - if err != nil { - t.Fatalf("open: %v", err) - } - if !bytes.Equal(opened, plaintext) { - t.Errorf("open returned %q; want %q", opened, plaintext) - } -} - -func TestAtmosphereEncryptor_NonceIsRandom(t *testing.T) { - enc, err := newAtmosphereEncryptor(mustAtmoKey(t)) - if err != nil { - t.Fatalf("newAtmosphereEncryptor: %v", err) - } - plaintext := []byte("same-input") - a, err := enc.seal(plaintext) - if err != nil { - t.Fatalf("seal a: %v", err) - } - b, err := enc.seal(plaintext) - if err != nil { - t.Fatalf("seal b: %v", err) - } - if a == b { - t.Fatal("two seals of the same plaintext produced identical output (nonce not randomized?)") - } -} - -func TestAtmosphereEncryptor_RejectsWrongKey(t *testing.T) { - encA, _ := newAtmosphereEncryptor(mustAtmoKey(t)) - encB, _ := newAtmosphereEncryptor(mustAtmoKey(t)) - sealed, err := encA.seal([]byte("secret")) - if err != nil { - t.Fatalf("seal: %v", err) - } - if _, err := encB.open(sealed); err == nil { - t.Fatal("open succeeded with wrong key") - } -} - -func TestAtmosphereEncryptor_RejectsBadCiphertext(t *testing.T) { - enc, _ := newAtmosphereEncryptor(mustAtmoKey(t)) - if _, err := enc.open("not base64!"); err == nil { - t.Fatal("open accepted non-base64 input") - } - if _, err := enc.open("dG9vc2hvcnQ="); err == nil { - t.Fatal("open accepted too-short ciphertext") - } -} - -func TestNewAtmosphereEncryptor_RejectsWrongKeyLength(t *testing.T) { - if _, err := newAtmosphereEncryptor(make([]byte, 16)); err == nil { - t.Fatal("16-byte key accepted; want rejected") - } - if _, err := newAtmosphereEncryptor(make([]byte, 64)); err == nil { - t.Fatal("64-byte key accepted; want rejected") - } -} - -// TestFormatPost_HealthStatusVerbatim: when the target supplied an -// X-Health-Status header, the post body is exactly that — no prefix, -// no metadata. Users put their own context in the header. -func TestFormatPost_HealthStatusVerbatim(t *testing.T) { - obs := observation{HTTPStatus: 503, HealthStatus: "db unreachable: timeout after 5s"} - got := formatPost(obs) - if got != obs.HealthStatus { - t.Errorf("post text = %q; want verbatim %q", got, obs.HealthStatus) - } -} - -// TestFormatPost_FallbackPerError: each error category gets a -// descriptive message that names what failed and whether the user can -// fix it. Strings are stable; if a test needs to change here, the docs -// in atmosphere.go and openapi.json's AtmosphereBindRequest description -// likely need a matching tweak. -func TestFormatPost_FallbackPerError(t *testing.T) { - cases := map[string]string{ - errCatTokenMismatch: "X-PingArthur-Token", - errCatBlocked: "SSRF blocklist", - errCatDNS: "DNS lookup failed", - errCatTLS: "TLS handshake failed", - errCatTimeout: "timed out", - errCatRefused: "TCP connection refused", - errCatOther: "Probe failed", - } - for cat, marker := range cases { - got := formatPost(observation{Error: cat}) - if !strings.Contains(got, marker) { - t.Errorf("error %q: post = %q; want substring %q", cat, got, marker) - } - if runeLen(got) > atmospherePostTextMax { - t.Errorf("error %q: post length = %d runes; want ≤ %d", cat, runeLen(got), atmospherePostTextMax) - } - } -} - -// TestFormatPost_FallbackHTTPStatus: no header, no error (token matched, -// target healthy, but no X-Health-Status set). Post nudges the user to -// set the header. -func TestFormatPost_FallbackHTTPStatus(t *testing.T) { - obs := observation{HTTPStatus: 200} - got := formatPost(obs) - if !strings.Contains(got, "HTTP 200") || !strings.Contains(got, "X-Health-Status") { - t.Errorf("post = %q; want HTTP 200 + X-Health-Status mention", got) - } -} - -// TestFormatPost_FallbackEmpty: zero-value observation → placeholder. -// Bluesky requires non-empty post text; we never post the empty string. -func TestFormatPost_FallbackEmpty(t *testing.T) { - got := formatPost(observation{}) - if got == "" { - t.Fatal("post text empty; Bluesky requires non-empty") - } - if !strings.Contains(got, "No signal") { - t.Errorf("post = %q; want 'No signal' fallback", got) - } -} - -// TestFormatPost_TruncatesOversize: ingest cap should already keep us -// safe, but if a longer string slips through (e.g. fallback growth) the -// rune-based final truncate kicks in with an ellipsis. -func TestFormatPost_TruncatesOversize(t *testing.T) { - obs := observation{HealthStatus: strings.Repeat("a", atmospherePostTextMax+50)} - text := formatPost(obs) - if runeLen(text) > atmospherePostTextMax { - t.Errorf("post text length = %d runes; want ≤ %d", runeLen(text), atmospherePostTextMax) - } - if !strings.HasSuffix(text, "…") { - t.Errorf("oversize text not ellipsis-suffixed: %q", text) - } -} - -// ─────────────────── atproto client integration (httptest) ─────────────────── - -// mockAtproto is a tiny in-process stand-in for the appview + PDS. Serves -// resolveHandle, plc.directory-shaped DID docs, listRecords, createSession, -// and createRecord. Tests construct one, wire its URL as handle resolver / -// PDS endpoints via direct injection into the atproto client helpers. -type mockAtproto struct { - t *testing.T - srv *httptest.Server - did string - posts int // records in app.bsky.feed.post - password string - created []map[string]any // captured createRecord payloads -} - -func newMockAtproto(t *testing.T) *mockAtproto { - t.Helper() - m := &mockAtproto{ - t: t, - did: "did:plc:fakefakefake", - password: "app-pass-1234", - } - mux := http.NewServeMux() - mux.HandleFunc("/xrpc/com.atproto.identity.resolveHandle", func(w http.ResponseWriter, r *http.Request) { - json.NewEncoder(w).Encode(map[string]any{"did": m.did}) - }) - // plc-style DID doc served at /plc/; tests rewrite DID doc URL to point here. - mux.HandleFunc("/plc/", func(w http.ResponseWriter, r *http.Request) { - json.NewEncoder(w).Encode(map[string]any{ - "service": []map[string]any{{ - "id": "#atproto_pds", - "type": "AtprotoPersonalDataServer", - "serviceEndpoint": m.srv.URL, - }}, - }) - }) - mux.HandleFunc("/xrpc/com.atproto.repo.listRecords", func(w http.ResponseWriter, r *http.Request) { - records := []map[string]any{} - for range m.posts { - records = append(records, map[string]any{}) - } - json.NewEncoder(w).Encode(map[string]any{"records": records}) - }) - mux.HandleFunc("/xrpc/com.atproto.server.createSession", func(w http.ResponseWriter, r *http.Request) { - var body createSessionReq - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "bad json", 400) - return - } - if body.Password != m.password { - http.Error(w, `{"error":"AuthenticationRequired"}`, http.StatusUnauthorized) - return - } - json.NewEncoder(w).Encode(map[string]any{"accessJwt": "fake-access", "refreshJwt": "fake-refresh"}) - }) - mux.HandleFunc("/xrpc/com.atproto.repo.createRecord", func(w http.ResponseWriter, r *http.Request) { - var body createRecordReq - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "bad json", 400) - return - } - m.created = append(m.created, body.Record) - json.NewEncoder(w).Encode(map[string]any{"uri": "at://example/app.bsky.feed.post/rkey"}) - }) - m.srv = httptest.NewServer(mux) - t.Cleanup(m.srv.Close) - return m -} - -// httpClient returns a plain http.Client (no SSRF rules) so tests can hit -// the httptest 127.0.0.1 target. -func (m *mockAtproto) httpClient() *http.Client { - return &http.Client{Timeout: 3 * time.Second} -} - -func TestAtprotoRepoEmpty_Empty(t *testing.T) { - m := newMockAtproto(t) - empty, err := atprotoRepoEmpty(context.Background(), m.httpClient(), m.srv.URL, m.did) - if err != nil { - t.Fatalf("atprotoRepoEmpty: %v", err) - } - if !empty { - t.Fatal("empty repo reported non-empty") - } -} - -func TestAtprotoRepoEmpty_NonEmpty(t *testing.T) { - m := newMockAtproto(t) - m.posts = 1 - empty, err := atprotoRepoEmpty(context.Background(), m.httpClient(), m.srv.URL, m.did) - if err != nil { - t.Fatalf("atprotoRepoEmpty: %v", err) - } - if empty { - t.Fatal("non-empty repo reported empty") - } -} - -func TestAtprotoCreateSession_Success(t *testing.T) { - m := newMockAtproto(t) - jwt, err := atprotoCreateSession(context.Background(), m.httpClient(), m.srv.URL, m.did, []byte(m.password)) - if err != nil { - t.Fatalf("createSession: %v", err) - } - if jwt != "fake-access" { - t.Errorf("jwt = %q; want fake-access", jwt) - } -} - -func TestAtprotoCreateSession_WrongPassword(t *testing.T) { - m := newMockAtproto(t) - _, err := atprotoCreateSession(context.Background(), m.httpClient(), m.srv.URL, m.did, []byte("wrong")) - if err == nil { - t.Fatal("createSession with wrong password should have errored") - } -} - -func TestAtprotoCreatePost_Success(t *testing.T) { - m := newMockAtproto(t) - const tokHash = "d41d8cd98f00b204e9800998ecf8427e" - err := atprotoCreatePost(context.Background(), m.httpClient(), m.srv.URL, m.did, "fake-access", "hello", tokHash) - if err != nil { - t.Fatalf("createPost: %v", err) - } - if len(m.created) != 1 { - t.Fatalf("created records = %d; want 1", len(m.created)) - } - rec := m.created[0] - if got := rec["text"]; got != "hello" { - t.Errorf("text = %q; want hello", got) - } - tags, ok := rec["tags"].([]any) - if !ok { - t.Fatalf("tags missing or wrong type: %#v", rec["tags"]) - } - if len(tags) != 2 || tags[0] != "PingArthur" || tags[1] != tokHash { - t.Errorf("tags = %v; want [PingArthur %s]", tags, tokHash) - } -} - -// TestHandleRE guards against obvious bad inputs slipping past to the -// appview call. Exact appview behavior tested in integration only. -func TestHandleRE(t *testing.T) { - valid := []string{ - "alice.bsky.social", - "a.b.c.example.com", - "status-foo.example.co.uk", - } - invalid := []string{ - "", - "no-dots", - ".leading.dot", - "trailing.dot.", - "has space.bsky.social", - "@atsign.bsky.social", - } - for _, h := range valid { - if !handleRE.MatchString(h) { - t.Errorf("handleRE rejected valid handle %q", h) - } - } - for _, h := range invalid { - if handleRE.MatchString(h) { - t.Errorf("handleRE accepted invalid handle %q", h) - } - } -} diff --git a/checker.go b/checker.go deleted file mode 100644 index a599af2..0000000 --- a/checker.go +++ /dev/null @@ -1,387 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "log" - "strconv" - "strings" - "time" - - clientv3 "go.etcd.io/etcd/client/v3" -) - -const ( - // Tighter than checkIntervalBase — most cycles will scan, find nothing - // due, and return. etcd handles thousands of ops/sec; the cost is small. - checkScanInterval = 5 * time.Second - checkScanLimit = 32 - claimedLeaseTTLSeconds = 60 - autoStopAfter = 24 * time.Hour - historyMaxN = 10 - scavengerInterval = time.Minute - // maxPostRetries bounds the convergent atmosphere retry per target status. - // A monitor may bind an arbitrary (non-PingArthur) Bluesky account, so an - // unbounded retry against a wrong/revoked credential would be a low-rate - // auth-amplification vector. At >=60s per check this is ~maxPostRetries - // minutes of retry for a transient PDS/DNS blip, then it gives up until the - // status changes again (which resets the counter). - maxPostRetries = 10 -) - -// checkFn matches the verifier shape but for the post-bind path: only the -// monitor's token hash is known, never the bearer. Production wires this to -// checkURL with the SSRF-safe client + HMAC secret. Tests inject fakes. -type checkFn func(ctx context.Context, url, expectedTokHash string) (bool, observation) - -// runChecker is the long-lived per-node loop. Scans the due/ prefix for -// entries at-or-before now, claims and processes each, and reschedules. -// Periodically runs the scavenger to recover monitors orphaned by a -// mid-check crash. All 3 nodes run this in parallel — claim CAS guarantees -// each monitor processes exactly once per cycle from whichever node wins. -// -// post fires from the claim-winning node whenever an observation's tuple -// changes AND the monitor has an atmosphere binding. Invoked in a detached -// goroutine so a slow PDS never stalls the check grid. nil disables posting -// (used by tests). -func runChecker(ctx context.Context, etcd *clientv3.Client, check checkFn, post atmospherePostFn) { - scanTicker := time.NewTicker(checkScanInterval) - defer scanTicker.Stop() - scavTicker := time.NewTicker(scavengerInterval) - defer scavTicker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-scanTicker.C: - scanDue(ctx, etcd, check, post) - case <-scavTicker.C: - scavenge(ctx, etcd) - } - } -} - -// scanDue fetches up to checkScanLimit due entries (whose padded timestamp -// is at-or-before now) and processes each. The keyspace's lexicographic -// order matches chronological order, so a single ordered range scan returns -// the oldest-due entries first. -func scanDue(ctx context.Context, etcd *clientv3.Client, check checkFn, post atmospherePostFn) { - metricCheckerScansTotal.Inc() - // dueKey(t, "") gives "due//"; using t = now+1s as the - // exclusive upper bound captures everything padded ≤ now. - upper := dueKey(time.Now().Add(time.Second), "") - resp, err := etcd.Get(ctx, keyPrefixDue, - clientv3.WithRange(upper), - clientv3.WithLimit(checkScanLimit), - clientv3.WithSort(clientv3.SortByKey, clientv3.SortAscend)) - if err != nil { - if ctx.Err() != nil { - return - } - log.Fatalf("checker scan: %v", err) - } - for _, kv := range resp.Kvs { - tokHash, scheduled, ok := parseDueKey(string(kv.Key)) - if !ok { - log.Fatalf("malformed due key: %q", kv.Key) - } - processDue(ctx, etcd, check, post, string(kv.Key), tokHash, scheduled) - } -} - -// parseDueKey extracts the token hash AND the scheduled timestamp from a -// "due//" key. Returns ok=false on a malformed key. -// The timestamp is needed by checkOne to schedule the next check on the -// original grid (previous_scheduled + interval), not relative to wall -// clock — keeps monitors phase-locked to the offset picked at bind time. -func parseDueKey(key string) (string, time.Time, bool) { - rest, ok := strings.CutPrefix(key, keyPrefixDue) - if !ok { - return "", time.Time{}, false - } - parts := strings.SplitN(rest, "/", 2) - if len(parts) != 2 { - return "", time.Time{}, false - } - ts, err := strconv.ParseInt(parts[0], 10, 64) - if err != nil { - return "", time.Time{}, false - } - return parts[1], time.Unix(ts, 0).UTC(), true -} - -// processDue claims a single due entry via CAS and runs the check. Failure -// to claim means another worker won the race — silent return; that's the -// designed behavior, not an error. -func processDue(ctx context.Context, etcd *clientv3.Client, check checkFn, post atmospherePostFn, dueK, tokHash string, scheduled time.Time) { - lease, err := etcd.Grant(ctx, claimedLeaseTTLSeconds) - if err != nil { - if ctx.Err() != nil { - return - } - log.Fatalf("grant claim lease: %v", err) - } - - resp, err := etcd.Txn(ctx). - If( - clientv3.Compare(clientv3.CreateRevision(dueK), "!=", 0), - clientv3.Compare(clientv3.CreateRevision(claimedKey(tokHash)), "=", 0), - ). - Then( - clientv3.OpDelete(dueK), - clientv3.OpPut(claimedKey(tokHash), "", clientv3.WithLease(lease.ID)), - ). - Commit() - if err != nil { - if ctx.Err() != nil { - return - } - log.Fatalf("claim txn: %v", err) - } - if !resp.Succeeded { - _, _ = etcd.Revoke(ctx, lease.ID) - return - } - - checkOne(ctx, etcd, check, post, tokHash, lease.ID, scheduled) -} - -// checkOne runs the verify, decides whether the observation tuple changed, -// folds the result into the blob, and reschedules. Auto-stops after 24h of -// missing/wrong token echo. The lease is revoked on the way out via defer -// so a panic in any branch still releases it. -// -// scheduled is the original due timestamp parsed from the due key — used -// to compute the next check on the same grid (scheduled + interval) rather -// than from wall clock, so monitors stay phase-locked to the offset picked -// at bind time even if processing was delayed. -func checkOne(ctx context.Context, etcd *clientv3.Client, check checkFn, post atmospherePostFn, tokHash string, lease clientv3.LeaseID, scheduled time.Time) { - defer func() { _, _ = etcd.Revoke(ctx, lease) }() - - blobResp, err := etcd.Get(ctx, monitorKey(tokHash)) - if err != nil { - if ctx.Err() != nil { - return - } - log.Fatalf("checker get blob: %v", err) - } - if blobResp.Count == 0 { - // Monitor was deleted after we claimed (auto-stop from a prior pass, - // race with manual teardown, etc.). Lease revoke clears the claim. - return - } - var blob monitorBlob - if err := json.Unmarshal(blobResp.Kvs[0].Value, &blob); err != nil { - log.Fatalf("unmarshal blob: %v", err) - } - blobModRev := blobResp.Kvs[0].ModRevision - - start := time.Now() - matched, obs := check(ctx, blob.URL, tokHash) - metricCheckDuration.Observe(time.Since(start).Seconds()) - metricCheckTotal.WithLabelValues(checkResultLabel(matched, obs)).Inc() - if matched { - metricCheckLastSuccess.SetToCurrentTime() - } - now := time.Now().UTC() - - if matched { - blob.LastTokenSeenAt = now - } else if now.Sub(blob.LastTokenSeenAt) > autoStopAfter { - autoStop(ctx, etcd, tokHash, blob, blobModRev) - return - } - - // Split the two concerns the observation carries: - // - "what did we just see?" — always record, so current.observed_at is - // the timestamp of the most recent probe, not the most recent change - // - "did it change?" — only then push history + bump the change metric, - // so future action triggers (webhooks, notifications) key on real - // status transitions, not every heartbeat - changed := observationChanged(blob.Current, obs) - if changed { - metricStatusChangeTotal.Inc() - blob.History = append([]observation{blob.Current}, blob.History...) - if len(blob.History) > historyMaxN { - blob.History = blob.History[:historyMaxN] - } - // A genuine status change is a fresh target: reset the retry budget so a - // new status always gets maxPostRetries attempts even if the previous one - // exhausted them (e.g. its post permanently failed). - if blob.Atmosphere != nil { - blob.Atmosphere.PostFailCount = 0 - } - } - blob.Current = obs - - // Convergent posting (separate from the edge-triggered history above): post - // whenever the current observation differs from the last SUCCESSFULLY posted - // one — not merely when it changes. A change still triggers a post, but a post - // that failed (LastPostedSig not advanced) is retried on the next check until - // it lands, so a transient PDS/DNS blip can't strand the feed on a stale - // status until the next unrelated status change. Bounded two ways: at most one - // attempt per check (>=60s, never a tight loop), and at most maxPostRetries - // consecutive failures per target status (so an arbitrary-account binding with - // a bad credential can't be turned into an indefinite auth-amplification loop - // against its PDS). - needsPost := post != nil && blob.Atmosphere != nil && - obsSig(obs) != blob.Atmosphere.LastPostedSig && - blob.Atmosphere.PostFailCount < maxPostRetries - - // Reschedule on the original grid: scheduled + interval. If we fell more - // than one interval behind (etcd stall, very slow check), jump to - // now + interval to avoid replaying a backlog of past slots. - interval := blob.effectiveInterval() - next := scheduled.Add(interval) - if !next.After(now) { - next = now.Add(interval) - } - blobBytes, err := json.Marshal(blob) - if err != nil { - log.Fatalf("marshal blob: %v", err) - } - - commit, err := etcd.Txn(ctx). - If(clientv3.Compare(clientv3.ModRevision(monitorKey(tokHash)), "=", blobModRev)). - Then( - clientv3.OpPut(monitorKey(tokHash), string(blobBytes)), - clientv3.OpDelete(claimedKey(tokHash)), - clientv3.OpPut(dueKey(next, tokHash), ""), - ). - Commit() - if err != nil { - if ctx.Err() != nil { - return - } - log.Fatalf("checker commit: %v", err) - } - // !commit.Succeeded ⇒ blob changed under us (e.g. concurrent re-bind or - // auto-stop from a prior pass). Lease revoke clears the claim; scavenger - // will re-insert a due/ entry next pass if the monitor still exists. In - // that case we also skip the atmosphere post — the winning txn's writer - // (if any) is responsible for its own post. - if commit.Succeeded && needsPost { - // Detached from the claim lease's lifetime so a slow PDS doesn't - // starve other scheduled checks. Bounded internally by the post's - // own timeout; cancelled by the shutdown-propagated ctx. - go post(ctx, tokHash, obs) - } -} - -// observationChanged returns true if the (status, health, error) tuple -// differs. ObservedAt is excluded — every check has a new timestamp; we -// only push history when the *result* changes. -func observationChanged(a, b observation) bool { - return a.HTTPStatus != b.HTTPStatus || - a.HealthStatus != b.HealthStatus || - a.Error != b.Error -} - -// autoStop tears down a monitor that's gone autoStopAfter without a valid -// token echo. Per CLAUDE.md, this is silent — the user only sees it via -// /api/v1/status returning 404. CAS-gates the txn on monitor blob's -// ModRevision and the two counters' Values so a concurrent re-bind or -// concurrent autoStop on a different worker can't double-decrement. -func autoStop(ctx context.Context, etcd *clientv3.Client, tokHash string, blob monitorBlob, blobModRev int64) { - etldVal, etldNum, err := readCounter(ctx, etcd, etldCountKey(blob.ETLD1)) - if err != nil { - if ctx.Err() != nil { - return - } - log.Fatalf("auto-stop get etld_count: %v", err) - } - statsVal, statsNum, err := readCounter(ctx, etcd, keyStatsCount) - if err != nil { - if ctx.Err() != nil { - return - } - log.Fatalf("auto-stop get stats: %v", err) - } - newEtld := max(etldNum-1, 0) - newStats := max(statsNum-1, 0) - - if _, err := etcd.Txn(ctx). - If( - clientv3.Compare(clientv3.ModRevision(monitorKey(tokHash)), "=", blobModRev), - counterCmp(etldCountKey(blob.ETLD1), etldVal), - counterCmp(keyStatsCount, statsVal), - ). - Then( - clientv3.OpDelete(monitorKey(tokHash)), - clientv3.OpDelete(etldKey(blob.ETLD1, tokHash)), - clientv3.OpDelete(claimedKey(tokHash)), - clientv3.OpPut(etldCountKey(blob.ETLD1), strconv.FormatInt(newEtld, 10)), - clientv3.OpPut(keyStatsCount, strconv.FormatInt(newStats, 10)), - ).Commit(); err != nil { - if ctx.Err() != nil { - return - } - log.Fatalf("auto-stop txn: %v", err) - } - metricAutoStopTotal.Inc() -} - -// scavenge walks every monitor/ and re-inserts due// for any -// whose token hash appears in neither due/ nor claimed/. Recovers monitors -// orphaned by a worker that crashed between claim and reschedule (claim -// lease expires after claimedLeaseTTLSeconds, leaving the monitor with no -// due/ and no claimed/ until the next scavenger pass). -// -// Race: two scavengers running simultaneously could both insert a due/ -// entry. Worst case: one duplicate scan-and-claim attempt where the second -// claim CAS fails because claimed/ exists. One wasted HEAD per orphan -// recovery is fine for v0. -func scavenge(ctx context.Context, etcd *clientv3.Client) { - monsResp, err := etcd.Get(ctx, keyPrefixMonitor, clientv3.WithPrefix(), clientv3.WithKeysOnly()) - if err != nil { - if ctx.Err() != nil { - return - } - log.Fatalf("scavenge get monitors: %v", err) - } - if len(monsResp.Kvs) == 0 { - return - } - - tracked := make(map[string]struct{}, len(monsResp.Kvs)) - - duesResp, err := etcd.Get(ctx, keyPrefixDue, clientv3.WithPrefix(), clientv3.WithKeysOnly()) - if err != nil { - if ctx.Err() != nil { - return - } - log.Fatalf("scavenge get dues: %v", err) - } - for _, kv := range duesResp.Kvs { - if h, _, ok := parseDueKey(string(kv.Key)); ok { - tracked[h] = struct{}{} - } - } - - claimsResp, err := etcd.Get(ctx, keyPrefixClaimed, clientv3.WithPrefix(), clientv3.WithKeysOnly()) - if err != nil { - if ctx.Err() != nil { - return - } - log.Fatalf("scavenge get claims: %v", err) - } - for _, kv := range claimsResp.Kvs { - tracked[strings.TrimPrefix(string(kv.Key), keyPrefixClaimed)] = struct{}{} - } - - now := time.Now().UTC() - for _, kv := range monsResp.Kvs { - h := strings.TrimPrefix(string(kv.Key), keyPrefixMonitor) - if _, ok := tracked[h]; ok { - continue - } - if _, err := etcd.Put(ctx, dueKey(now, h), ""); err != nil { - if ctx.Err() != nil { - return - } - log.Fatalf("scavenge put due: %v", err) - } - metricScavengerRecovered.Inc() - } -} diff --git a/checker_test.go b/checker_test.go deleted file mode 100644 index 2780ad2..0000000 --- a/checker_test.go +++ /dev/null @@ -1,650 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "sync/atomic" - "testing" - "time" - - "github.com/prometheus/client_golang/prometheus/testutil" - clientv3 "go.etcd.io/etcd/client/v3" -) - -// bindOne is a test helper: binds a token and returns its tokHash. -// Replaces the `due/` entry with one timestamped in the past so the -// checker scan finds it immediately. -func bindOne(t *testing.T, cli *clientv3.Client, secret []byte, fakeMatched bool) (tok, tokHash string) { - t.Helper() - var calls atomic.Int64 - h := bindMonitorHandler(context.Background(), secret, cli, fakeVerifier(fakeMatched, &calls), 100, nil, nil) - tok = issueTestToken(secret) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, bindReq(tok, "https://www.example.com/")) - if rec.Code != http.StatusCreated { - t.Fatalf("seed bind: %d (%s); want 201", rec.Code, rec.Body.String()) - } - tokHash = tokenHash(tok) - return -} - -// makeOverdue replaces all due/ entries with a single backdated one for tokHash, -// so scanDue picks it up on the next call. -func makeOverdue(t *testing.T, cli *clientv3.Client, tokHash string) { - t.Helper() - ctx := context.Background() - if _, err := cli.Delete(ctx, keyPrefixDue, clientv3.WithPrefix()); err != nil { - t.Fatalf("delete dues: %v", err) - } - if _, err := cli.Put(ctx, dueKey(time.Now().Add(-5*time.Minute), tokHash), ""); err != nil { - t.Fatalf("put backdated due: %v", err) - } -} - -// readBlob fetches and unmarshals the monitor blob. Fatals if missing. -func readBlob(t *testing.T, cli *clientv3.Client, tokHash string) (monitorBlob, int64) { - t.Helper() - resp, err := cli.Get(context.Background(), monitorKey(tokHash)) - if err != nil { - t.Fatalf("get blob: %v", err) - } - if resp.Count != 1 { - t.Fatalf("blob count = %d; want 1", resp.Count) - } - var blob monitorBlob - if err := json.Unmarshal(resp.Kvs[0].Value, &blob); err != nil { - t.Fatalf("unmarshal blob: %v", err) - } - return blob, resp.Kvs[0].ModRevision -} - -// alwaysObserve returns a checkFn returning the supplied observation tuple. -func alwaysObserve(matched bool, status int, health, errCat string, calls *atomic.Int64) checkFn { - return func(_ context.Context, _, _ string) (bool, observation) { - if calls != nil { - calls.Add(1) - } - return matched, observation{ - HTTPStatus: status, - HealthStatus: health, - Error: errCat, - ObservedAt: time.Now().UTC(), - } - } -} - -func TestChecker_StatusChange_PushesHistory(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - _, tokHash := bindOne(t, cli, secret, true) - makeOverdue(t, cli, tokHash) - - var calls atomic.Int64 - scanDue(context.Background(), cli, alwaysObserve(true, 503, "down", "", &calls), nil) - - if calls.Load() != 1 { - t.Fatalf("checker calls = %d; want 1", calls.Load()) - } - blob, _ := readBlob(t, cli, tokHash) - if blob.Current.HTTPStatus != 503 { - t.Errorf("Current.HTTPStatus = %d; want 503", blob.Current.HTTPStatus) - } - if blob.Current.HealthStatus != "down" { - t.Errorf("Current.HealthStatus = %q; want down", blob.Current.HealthStatus) - } - if len(blob.History) != 1 { - t.Fatalf("History length = %d; want 1 (previous observation should be pushed)", len(blob.History)) - } - if blob.History[0].HTTPStatus != 200 { - t.Errorf("History[0].HTTPStatus = %d; want 200 (the bind-time observation)", blob.History[0].HTTPStatus) - } - - // A new due/ entry must have been written. - dueResp, _ := cli.Get(context.Background(), keyPrefixDue, clientv3.WithPrefix()) - if dueResp.Count != 1 { - t.Errorf("due/ count after reschedule = %d; want 1", dueResp.Count) - } - // claimed/ must be cleared. - claimedResp, _ := cli.Get(context.Background(), claimedKey(tokHash)) - if claimedResp.Count != 0 { - t.Errorf("claimed/ count = %d; want 0 (released after reschedule)", claimedResp.Count) - } -} - -// setAtmosphere injects a minimal atmosphere binding onto an existing monitor -// (bindOne binds without one). No CAS — test setup only. -func setAtmosphere(t *testing.T, cli *clientv3.Client, tokHash string) { - t.Helper() - blob, _ := readBlob(t, cli, tokHash) - blob.Atmosphere = &atmosphereBinding{Handle: "status.example", DID: "did:plc:test", PDSURL: "https://pds.example"} - b, err := json.Marshal(blob) - if err != nil { - t.Fatalf("marshal blob: %v", err) - } - if _, err := cli.Put(context.Background(), monitorKey(tokHash), string(b)); err != nil { - t.Fatalf("put blob: %v", err) - } -} - -// fakePost is an atmospherePostFn that mutates the blob like the real post does -// (success advances LastPostedSig; failure increments PostFailCount), so checkOne's -// convergent-retry decision can be exercised end-to-end. Toggle fail at runtime. -type fakePost struct { - calls atomic.Int64 - fail atomic.Bool - done chan struct{} -} - -func (f *fakePost) fn(cli *clientv3.Client) atmospherePostFn { - return func(ctx context.Context, tokHash string, obs observation) { - f.calls.Add(1) - if f.fail.Load() { - recordAtmosphereError(ctx, cli, tokHash, "simulated post failure") - } else { - recordAtmosphereSuccess(ctx, cli, tokHash, obsSig(obs)) - } - select { - case f.done <- struct{}{}: - default: - } - } -} - -// A failed post must be RETRIED on later checks even without a status change -// (the bug fix), but bounded at maxPostRetries per status (so an arbitrary-account -// binding with a bad credential can't become an indefinite auth-amplification -// loop), resetting on a genuine change, and going quiet once a post succeeds. -func TestChecker_FailedPost_RetriesBoundedResetsAndConverges(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - _, tokHash := bindOne(t, cli, secret, true) - setAtmosphere(t, cli, tokHash) - - fp := &fakePost{done: make(chan struct{}, 1)} - fp.fail.Store(true) - - check := func(status int, health string) { - makeOverdue(t, cli, tokHash) - scanDue(context.Background(), cli, alwaysObserve(true, status, health, "", nil), fp.fn(cli)) - } - waitPost := func() { - t.Helper() - select { - case <-fp.done: - case <-time.After(3 * time.Second): - t.Fatal("expected a post, none fired") - } - } - assertNoPost := func() { - t.Helper() - before := fp.calls.Load() - time.Sleep(100 * time.Millisecond) - if got := fp.calls.Load(); got != before { - t.Fatalf("expected no post; calls went %d -> %d", before, got) - } - } - - // Change to "down" → post fires (fails). - check(503, "down") - waitPost() - // SAME status, prior post failed → must retry without a change (the fix). - check(503, "down") - waitPost() - if got := fp.calls.Load(); got != 2 { - t.Fatalf("after retry-without-change: calls = %d; want 2", got) - } - blob, _ := readBlob(t, cli, tokHash) - if blob.Atmosphere.LastPostedSig != "" { - t.Errorf("LastPostedSig advanced despite failures: %q", blob.Atmosphere.LastPostedSig) - } - if blob.Atmosphere.PostFailCount != 2 { - t.Errorf("PostFailCount = %d; want 2", blob.Atmosphere.PostFailCount) - } - - // Keep failing up to the cap, then it must stop posting for this status. - for fp.calls.Load() < int64(maxPostRetries) { - check(503, "down") - waitPost() - } - check(503, "down") // cap reached - assertNoPost() - if got := fp.calls.Load(); got != int64(maxPostRetries) { - t.Fatalf("capped calls = %d; want %d", got, maxPostRetries) - } - - // A genuine status change resets the budget → posts again (still failing). - check(200, "ok") - waitPost() - - // Let posts succeed → sig advances → convergence (no repeat for same status). - fp.fail.Store(false) - check(200, "ok") - waitPost() - check(200, "ok") - assertNoPost() - blob, _ = readBlob(t, cli, tokHash) - if want := obsSig(observation{HTTPStatus: 200, HealthStatus: "ok"}); blob.Atmosphere.LastPostedSig != want { - t.Errorf("LastPostedSig = %q; want %q", blob.Atmosphere.LastPostedSig, want) - } - if blob.Atmosphere.PostFailCount != 0 { - t.Errorf("PostFailCount after success = %d; want 0", blob.Atmosphere.PostFailCount) - } -} - -func TestChecker_NoChange_LeavesHistoryEmpty(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - _, tokHash := bindOne(t, cli, secret, true) - - // Capture the bind-time ObservedAt so we can prove the no-change scan - // advances it — current is "the most recent probe", not "the most recent - // change". Action triggers (future) key on history growth / the change - // metric, not on current updates, so a heartbeat doesn't cause a false - // notification. - before, _ := readBlob(t, cli, tokHash) - bindObservedAt := before.Current.ObservedAt - time.Sleep(2 * time.Millisecond) // ensure any new timestamp is strictly later - - makeOverdue(t, cli, tokHash) - // Same tuple as the bind-time fake (200, "", ""). - scanDue(context.Background(), cli, alwaysObserve(true, 200, "", "", nil), nil) - - blob, _ := readBlob(t, cli, tokHash) - if len(blob.History) != 0 { - t.Errorf("History length = %d; want 0 (no change ⇒ no push)", len(blob.History)) - } - if blob.Current.HTTPStatus != 200 { - t.Errorf("Current.HTTPStatus = %d; want 200", blob.Current.HTTPStatus) - } - if !blob.Current.ObservedAt.After(bindObservedAt) { - t.Errorf("Current.ObservedAt = %v; want strictly later than bind-time %v (heartbeat must refresh)", - blob.Current.ObservedAt, bindObservedAt) - } -} - -func TestChecker_HistoryBoundedAtN(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - _, tokHash := bindOne(t, cli, secret, true) - - // Drive historyMaxN + 3 distinct status changes through the checker. - for i := range historyMaxN + 3 { - makeOverdue(t, cli, tokHash) - scanDue(context.Background(), cli, alwaysObserve(true, 500+i, "", "", nil), nil) - } - blob, _ := readBlob(t, cli, tokHash) - if len(blob.History) != historyMaxN { - t.Fatalf("History length = %d; want %d (truncated)", len(blob.History), historyMaxN) - } - // Newest first: the most recent push (just before the final iteration's - // new current) was status=500+(historyMaxN+2)-1. - wantNewest := 500 + historyMaxN + 1 - if blob.History[0].HTTPStatus != wantNewest { - t.Errorf("History[0].HTTPStatus = %d; want %d (newest first)", blob.History[0].HTTPStatus, wantNewest) - } -} - -func TestChecker_AutoStop_After24h(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - _, tokHash := bindOne(t, cli, secret, true) - makeOverdue(t, cli, tokHash) - - // Backdate last_token_seen_at to 25h ago. - ctx := context.Background() - blobResp, _ := cli.Get(ctx, monitorKey(tokHash)) - var blob monitorBlob - _ = json.Unmarshal(blobResp.Kvs[0].Value, &blob) - blob.LastTokenSeenAt = time.Now().Add(-25 * time.Hour).UTC() - bb, _ := json.Marshal(blob) - cli.Put(ctx, monitorKey(tokHash), string(bb)) - - // Snapshot the autostop counter so we can assert the delta — tests can - // run in any order and this metric is a package-level singleton. - autoStopBefore := testutil.ToFloat64(metricAutoStopTotal) - - // Checker returns matched=false → triggers auto-stop path. - scanDue(ctx, cli, alwaysObserve(false, 200, "", errCatTokenMismatch, nil), nil) - - if got := testutil.ToFloat64(metricAutoStopTotal) - autoStopBefore; got != 1 { - t.Errorf("pa_monitor_autostop_total delta = %v; want 1", got) - } - - mon, _ := cli.Get(ctx, monitorKey(tokHash)) - if mon.Count != 0 { - t.Errorf("monitor key still present after auto-stop: count=%d", mon.Count) - } - etldResp, _ := cli.Get(ctx, etldKey(blob.ETLD1, tokHash)) - if etldResp.Count != 0 { - t.Errorf("etld// still present after auto-stop: count=%d", etldResp.Count) - } - statsResp, _ := cli.Get(ctx, keyStatsCount) - if string(statsResp.Kvs[0].Value) != "0" { - t.Errorf("stats/monitor_count = %q; want 0 after auto-stop", statsResp.Kvs[0].Value) - } - etldCntResp, _ := cli.Get(ctx, etldCountKey(blob.ETLD1)) - if string(etldCntResp.Kvs[0].Value) != "0" { - t.Errorf("etld_count/ = %q; want 0 after auto-stop", etldCntResp.Kvs[0].Value) - } - dueResp, _ := cli.Get(ctx, keyPrefixDue, clientv3.WithPrefix()) - if dueResp.Count != 0 { - t.Errorf("due/ count = %d; want 0 (no reschedule on auto-stop)", dueResp.Count) - } -} - -func TestChecker_TokenMissing_BeforeAutoStop_NoTeardown(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - _, tokHash := bindOne(t, cli, secret, true) - makeOverdue(t, cli, tokHash) - - // last_token_seen_at is fresh (set by bind), so a single token-missing check - // must NOT auto-stop. It should record the token_mismatch error in the blob. - scanDue(context.Background(), cli, alwaysObserve(false, 200, "", errCatTokenMismatch, nil), nil) - - blob, _ := readBlob(t, cli, tokHash) - if blob.Current.Error != errCatTokenMismatch { - t.Errorf("Current.Error = %q; want %q", blob.Current.Error, errCatTokenMismatch) - } - // History should hold the bind-time success. - if len(blob.History) != 1 || blob.History[0].Error != "" { - t.Errorf("History = %+v; want single empty-error entry from bind", blob.History) - } -} - -func TestChecker_ClaimRace_OnlyOneSucceeds(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - _, tokHash := bindOne(t, cli, secret, true) - makeOverdue(t, cli, tokHash) - - // Two scans on the same etcd state: only the first claims. The second - // finds the due/ entry already gone and the claim fails. Use a counter - // to assert the verifier is called exactly once. - var calls atomic.Int64 - check := alwaysObserve(true, 200, "", "", &calls) - - scheduled := time.Now().Add(-5 * time.Minute).UTC() - dueK := dueKey(scheduled, tokHash) - processDue(context.Background(), cli, check, nil, dueK, tokHash, scheduled) - processDue(context.Background(), cli, check, nil, dueK, tokHash, scheduled) - - if calls.Load() != 1 { - t.Fatalf("verifier calls = %d; want 1 (claim CAS must serialize)", calls.Load()) - } -} - -func TestChecker_Scavenger_ReinsertsOrphans(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - _, tokHash := bindOne(t, cli, secret, true) - - // Simulate a crashed worker mid-check: delete the due/ entry, no claimed/. - ctx := context.Background() - if _, err := cli.Delete(ctx, keyPrefixDue, clientv3.WithPrefix()); err != nil { - t.Fatalf("delete dues: %v", err) - } - - scavenge(ctx, cli) - - dueResp, _ := cli.Get(ctx, keyPrefixDue, clientv3.WithPrefix()) - if dueResp.Count != 1 { - t.Fatalf("due/ count after scavenge = %d; want 1", dueResp.Count) - } - // And the recovered entry must point at our orphaned monitor. - got, _, ok := parseDueKey(string(dueResp.Kvs[0].Key)) - if !ok || got != tokHash { - t.Errorf("recovered due key = %q (parsed=%q); want token hash %q", dueResp.Kvs[0].Key, got, tokHash) - } -} - -func TestChecker_Scavenger_LeavesTrackedAlone(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - _, tokHash := bindOne(t, cli, secret, true) - - // A tracked monitor (has due/ from bind). Scavenger should not add a duplicate. - scavenge(context.Background(), cli) - dueResp, _ := cli.Get(context.Background(), keyPrefixDue, clientv3.WithPrefix()) - if dueResp.Count != 1 { - t.Errorf("due/ count after scavenge = %d; want 1 (tracked, not re-inserted)", dueResp.Count) - } - _ = tokHash -} - -func TestParseDueKey(t *testing.T) { - cases := map[string]struct { - wantHash string - wantTS time.Time - ok bool - }{ - dueKey(time.Unix(1700000000, 0), "abc123"): {"abc123", time.Unix(1700000000, 0).UTC(), true}, - "due/00000000000001700000000/abc123": {"abc123", time.Unix(1700000000, 0).UTC(), true}, - "due/00000000000001700000000/has/extra/slashes": {"has/extra/slashes", time.Unix(1700000000, 0).UTC(), true}, - "monitor/abc": {"", time.Time{}, false}, - "due/justone": {"", time.Time{}, false}, - "due/": {"", time.Time{}, false}, - "due/notanumber/hash": {"", time.Time{}, false}, - } - for in, want := range cases { - gotHash, gotTS, ok := parseDueKey(in) - if gotHash != want.wantHash || ok != want.ok || !gotTS.Equal(want.wantTS) { - t.Errorf("parseDueKey(%q) = (%q, %v, %v); want (%q, %v, %v)", - in, gotHash, gotTS, ok, want.wantHash, want.wantTS, want.ok) - } - } -} - -func TestObservationChanged(t *testing.T) { - base := observation{HTTPStatus: 200, HealthStatus: "ok", Error: ""} - cases := map[string]struct { - other observation - want bool - }{ - "identical": {base, false}, - "only timestamp": {observation{HTTPStatus: 200, HealthStatus: "ok", Error: "", ObservedAt: time.Now()}, false}, - "status differs": {observation{HTTPStatus: 503, HealthStatus: "ok", Error: ""}, true}, - "health differs": {observation{HTTPStatus: 200, HealthStatus: "warn", Error: ""}, true}, - "error differs": {observation{HTTPStatus: 200, HealthStatus: "ok", Error: errCatTLS}, true}, - } - for name, c := range cases { - t.Run(name, func(t *testing.T) { - if got := observationChanged(base, c.other); got != c.want { - t.Errorf("observationChanged = %v; want %v", got, c.want) - } - }) - } -} - -// TestCheckURL_HMACValidAndHashMatches drives the real check path against a -// test TLS server that echoes a real (HMAC-signed) bound token. -func TestCheckURL_HMACValidAndHashMatches(t *testing.T) { - secret := mustSecret(t) - tok, _ := issueToken(secret, time.Now()) - expected := tokenHash(tok) - - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-PingArthur-Token", tok) - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - - matched, obs := checkURL(context.Background(), testClient(srv), secret, srv.URL, expected) - if !matched { - t.Fatalf("matched = false; want true (obs=%+v)", obs) - } - if obs.HTTPStatus != 200 { - t.Errorf("HTTPStatus = %d; want 200", obs.HTTPStatus) - } - if obs.Error != "" { - t.Errorf("Error = %q; want empty", obs.Error) - } -} - -func TestCheckURL_HashMismatch(t *testing.T) { - secret := mustSecret(t) - tok, _ := issueToken(secret, time.Now()) - differentHash := tokenHash("pa_some_other_token") // no HMAC needed; just non-matching bytes - - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-PingArthur-Token", tok) - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - - matched, obs := checkURL(context.Background(), testClient(srv), secret, srv.URL, differentHash) - if matched { - t.Fatal("matched = true; want false (HMAC valid but hash mismatch)") - } - if obs.Error != errCatTokenMismatch { - t.Errorf("Error = %q; want %q", obs.Error, errCatTokenMismatch) - } -} - -func TestCheckURL_HMACInvalid(t *testing.T) { - secret := mustSecret(t) - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Echo a string with the right prefix but garbage body — HMAC fails. - w.Header().Set("X-PingArthur-Token", "pa_AAAAAAAAAAAAAAAAAAAAAAAA") - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - - matched, obs := checkURL(context.Background(), testClient(srv), secret, srv.URL, tokenHash("anything")) - if matched { - t.Fatal("matched = true; want false (HMAC invalid)") - } - if obs.Error != errCatTokenMismatch { - t.Errorf("Error = %q; want %q", obs.Error, errCatTokenMismatch) - } -} - -func TestVerifyTokenSignature_ChecksHMACWithoutExpiry(t *testing.T) { - secret := mustSecret(t) - // Issue a token "in the past" outside the bind window. - tok, _ := issueToken(secret, time.Now().Add(-2*time.Hour)) - - if !verifyTokenSignature(secret, tok) { - t.Fatal("verifyTokenSignature rejected an HMAC-valid token (past the bind window — signature still valid)") - } - // And verifyToken (with expiry) rejects it. - if _, ok := verifyToken(secret, tok, time.Now()); ok { - t.Fatal("verifyToken accepted a token past bind window — expiry check is gone") - } -} - -// Also confirm we never end up with both a due/ and a claimed/ for the same -// monitor at the same time when scavenger races with a successful claim. -func TestChecker_Scavenger_SkipsClaimed(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - _, tokHash := bindOne(t, cli, secret, true) - - // Drop the due/ but install a claimed/ (simulating a worker mid-check). - ctx := context.Background() - if _, err := cli.Delete(ctx, keyPrefixDue, clientv3.WithPrefix()); err != nil { - t.Fatalf("delete dues: %v", err) - } - lease, _ := cli.Grant(ctx, claimedLeaseTTLSeconds) - if _, err := cli.Put(ctx, claimedKey(tokHash), "", clientv3.WithLease(lease.ID)); err != nil { - t.Fatalf("put claimed: %v", err) - } - - scavenge(ctx, cli) - dueResp, _ := cli.Get(ctx, keyPrefixDue, clientv3.WithPrefix()) - if dueResp.Count != 0 { - t.Errorf("scavenger added due/ for monitor with active claim: count=%d", dueResp.Count) - } -} - -func TestChecker_RestoresAcrossMultipleScans(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - _, tokHash := bindOne(t, cli, secret, true) - - // Force first check to be due now, then run two scan cycles in succession. - makeOverdue(t, cli, tokHash) - scanDue(context.Background(), cli, alwaysObserve(true, 200, "", "", nil), nil) - // After the first scan, the next due/ is ~60min in the future. Second scan should be a no-op. - makeOverdue(t, cli, tokHash) // pretend an hour passed - scanDue(context.Background(), cli, alwaysObserve(true, 503, "", "", nil), nil) - - blob, _ := readBlob(t, cli, tokHash) - if blob.Current.HTTPStatus != 503 { - t.Errorf("Current.HTTPStatus after two scans = %d; want 503", blob.Current.HTTPStatus) - } - if len(blob.History) != 1 { - t.Errorf("History len = %d; want 1 (only one status change pushed)", len(blob.History)) - } -} - -// Sanity: dueKey + parseDueKey roundtrip on a real-looking input. -func TestDueKey_Roundtrip(t *testing.T) { - now := time.Now().UTC().Truncate(time.Second) - h := tokenHash("pa_xyz") - parsedHash, parsedTS, ok := parseDueKey(dueKey(now, h)) - if !ok || parsedHash != h || !parsedTS.Equal(now) { - t.Errorf("roundtrip failed: dueKey(%v, %q) → parseDueKey = (%q, %v, %v)", - now, h, parsedHash, parsedTS, ok) - } -} - -// After a check, the next due/ key lands on the scheduled+interval grid, -// not at now+interval — so monitors stay phase-locked regardless of how -// late processing happened. -func TestChecker_RescheduleLandsOnGrid(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - _, tokHash := bindOne(t, cli, secret, true) - - // Rewrite the blob with a short interval so we can assert the grid - // without waiting an hour. - ctx := context.Background() - blob, _ := readBlob(t, cli, tokHash) - blob.IntervalSeconds = 120 - blobBytes, _ := json.Marshal(blob) - if _, err := cli.Put(ctx, monitorKey(tokHash), string(blobBytes)); err != nil { - t.Fatalf("put blob: %v", err) - } - - // Backdate the due/ key so scanDue picks it up, and note the exact - // scheduled timestamp we seed — that's the grid anchor. - if _, err := cli.Delete(ctx, keyPrefixDue, clientv3.WithPrefix()); err != nil { - t.Fatalf("delete dues: %v", err) - } - scheduled := time.Now().Add(-30 * time.Second).UTC().Truncate(time.Second) - if _, err := cli.Put(ctx, dueKey(scheduled, tokHash), ""); err != nil { - t.Fatalf("put due: %v", err) - } - - scanDue(ctx, cli, alwaysObserve(true, 200, "", "", nil), nil) - - // Exactly one due/ entry, at scheduled + interval (120s), not shifted - // forward to now + 120s. - dueResp, _ := cli.Get(ctx, keyPrefixDue, clientv3.WithPrefix()) - if dueResp.Count != 1 { - t.Fatalf("due/ count = %d; want 1", dueResp.Count) - } - _, gotTS, ok := parseDueKey(string(dueResp.Kvs[0].Key)) - if !ok { - t.Fatalf("could not parse new due key %q", dueResp.Kvs[0].Key) - } - want := scheduled.Add(120 * time.Second) - if !gotTS.Equal(want) { - t.Errorf("next scheduled = %v; want %v (grid = scheduled + interval)", gotTS, want) - } -} - -// Legacy blobs (IntervalSeconds unset) still get the 1h default. -func TestEffectiveInterval_DefaultOnUnset(t *testing.T) { - var b monitorBlob - if got := b.effectiveInterval(); got != time.Hour { - t.Errorf("effectiveInterval on unset = %v; want 1h", got) - } - b.IntervalSeconds = 60 - if got := b.effectiveInterval(); got != time.Minute { - t.Errorf("effectiveInterval on 60 = %v; want 1m", got) - } -} diff --git a/cmd/canary/main.go b/cmd/canary/main.go deleted file mode 100644 index c9997f7..0000000 --- a/cmd/canary/main.go +++ /dev/null @@ -1,405 +0,0 @@ -// Canary is a deliberately-misbehaving HTTP target used to verify that -// pingarthur's full pipeline works end-to-end. It cycles through 7 known -// states on a wall-clock schedule (one state per minute, 7-minute loop), -// exercising every error category pingarthur normalises. -// -// Deployed on divepool edge (separate failure domain from the pingarthur -// cluster) at https://canary.pingarthur.com. Pingarthur monitors that URL -// with interval=60s; the observed status history should match the cycle -// exactly. If it doesn't, pingarthur is broken. -// -// State selection is a pure function of time.Now(), so every node serving -// the canary computes identical responses. No coordination needed. -// -// The echoed X-PingArthur-Token must be the exact bearer that was used -// when pingarthur bound this URL (monitor identity = token hash, checker -// validates HMAC + hash-match). Set CANARY_ECHO_TOKEN in the env file. -// -// The canary also serves two auxiliary endpoints: -// -// GET /healthz → constant 200; external liveness check -// GET /expected-state → JSON of the state pingarthur SHOULD be recording -// right now. Used by sync-check + tooling. -// GET /sync-check → compares pingarthur's `/api/v1/status` against -// what this canary knows should be the current -// observation. 200 if in sync (echoes -// SYNC_CHECK_ECHO_TOKEN for the second pingarthur -// monitor), 503 with a diff body otherwise. -// Both HEAD and GET are supported. Set -// SYNC_CHECK_ECHO_TOKEN + PINGARTHUR_BASE_URL. -package main - -import ( - "context" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "os" - "strings" - "time" -) - -const ( - cycleStates = 7 - stateDurationSec = 60 - stateDuration = stateDurationSec * time.Second - // Exceeds pingarthur's 10s total request deadline, so the checker's - // client aborts with a timeout classification. - state4SleepDuration = 12 * time.Second - // state5LongHealthLen sends an X-Health-Status well over pingarthur's - // rune-cap so the observation records exactly the first - // pingarthurHealthStatusMax runes — proves the truncation path is wired. - state5LongHealthLen = 500 - // state6JunkHeaderCount × state6JunkValueLen has to exceed - // pingarthur's 16 KiB MaxResponseHeaderBytes. 128 × 256 = 32 KiB. - state6JunkHeaderCount = 128 - state6JunkValueLen = 256 - // pingarthurHealthStatusMax mirrors verify.go::healthStatusMax. - // Pingarthur rune-caps X-Health-Status; sync-check reconstructs the - // expected observed value by doing the same rune-cap locally. - pingarthurHealthStatusMax = 280 - // syncCheckStaleAfter: if pingarthur's most-recent observation is - // older than this, sync-check fails regardless of tuple content — - // the checker has silently stopped. Three intervals is comfortably - // past any single-check lag. - syncCheckStaleAfter = 3 * stateDuration - // syncCheckHTTPTimeout bounds the outbound call to pingarthur's - // /api/v1/status; must be shorter than pingarthur's 10s outbound - // probe deadline so a bad response doesn't cascade timeouts. - syncCheckHTTPTimeout = 5 * time.Second -) - -type state struct { - status int - echoToken bool - healthStatus string - sleepDuration time.Duration - // junkHeaders tells the handler to flood the response with many - // junk headers until the aggregate size exceeds pingarthur's cap. - // The specific content doesn't matter — we're testing the cap. - junkHeaders bool -} - -// The 7 cycle states, in order. Every transition changes at least one -// field of the observation tuple (status, health_status, or error), so -// pingarthur records one history entry per minute boundary. -var cycle = [cycleStates]state{ - 0: {status: 200, echoToken: true, healthStatus: "healthy"}, - 1: {status: 200, echoToken: true, healthStatus: "degraded"}, - 2: {status: 503, echoToken: true}, - 3: {status: 200, echoToken: false}, // → token_mismatch - 4: {status: 200, echoToken: true, sleepDuration: state4SleepDuration}, // → timeout - 5: {status: 200, echoToken: true, healthStatus: strings.Repeat("a", state5LongHealthLen)}, - 6: {status: 200, echoToken: true, junkHeaders: true}, // → "other" -} - -func currentState(now time.Time) (int, state) { - i := int(now.Unix()/stateDurationSec) % cycleStates - return i, cycle[i] -} - -// expectedObservation is the tuple pingarthur SHOULD record for a given -// cycle state. Derived from the canary's behaviour + pingarthur's known -// normalisation (truncation, error categorisation). -type expectedObservation struct { - HTTPStatus int `json:"http_status"` - HealthStatus string `json:"health_status"` - Error string `json:"error"` -} - -func expectedObservationFor(st state) expectedObservation { - switch { - case st.sleepDuration > 0: - // Pingarthur's client aborts at 10s → timeout category. - return expectedObservation{HTTPStatus: 0, Error: "timeout"} - case st.junkHeaders: - // Pingarthur's transport refuses headers > 16 KiB → falls - // through classifyNetErr to "other". - return expectedObservation{HTTPStatus: 0, Error: "other"} - case !st.echoToken: - // Missing X-PingArthur-Token → token_mismatch. Status code is - // still whatever the target returned. - return expectedObservation{HTTPStatus: st.status, Error: "token_mismatch"} - } - // Token echoed correctly: record the literal status + the rune-capped - // health value. verify.go::headWith runs ToValidUTF8 first, then rune- - // caps; our canary's long-health state uses ASCII so the ToValidUTF8 - // step is a no-op here. - hs := st.healthStatus - if r := []rune(hs); len(r) > pingarthurHealthStatusMax { - hs = string(r[:pingarthurHealthStatusMax]) - } - return expectedObservation{HTTPStatus: st.status, HealthStatus: hs} -} - -// cycleHandler serves the main target — every request gets the -// behaviour of the CURRENT cycle state (via pickState). -func cycleHandler(echoToken string, pickState func() state) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - st := pickState() - - if st.sleepDuration > 0 { - select { - case <-time.After(st.sleepDuration): - case <-r.Context().Done(): - return - } - } - - if st.echoToken { - w.Header().Set("X-PingArthur-Token", echoToken) - } - if st.healthStatus != "" { - w.Header().Set("X-Health-Status", st.healthStatus) - } - if st.junkHeaders { - payload := strings.Repeat("x", state6JunkValueLen) - for i := range state6JunkHeaderCount { - w.Header().Set("X-Junk-"+strings.Repeat("A", 4)+keySuffix(i), payload) - } - } - w.WriteHeader(st.status) - } -} - -// expectedStateHandler returns JSON describing what pingarthur should be -// observing RIGHT NOW. Does not echo any pingarthur token — not meant to -// be a pingarthur monitor target, just a diagnostic endpoint. -func expectedStateHandler(pickState func() (int, state)) http.HandlerFunc { - return func(w http.ResponseWriter, _ *http.Request) { - idx, st := pickState() - payload := struct { - CycleIndex int `json:"cycle_index"` - Expected expectedObservation `json:"expected"` - }{ - CycleIndex: idx, - Expected: expectedObservationFor(st), - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(payload) - } -} - -// syncCheckHandler queries pingarthur for the canary's observation and -// compares against what we KNOW should be recorded. Returns 200 + echoes -// SYNC_CHECK_ECHO_TOKEN when in sync, 503 + echoes the same token -// otherwise (so pingarthur's second monitor binds against this endpoint -// and records the 200↔503 transition in its own history). -// -// pickStateAt decouples from wall clock for tests; production passes in -// currentState. -func syncCheckHandler( - canaryEchoToken, syncEchoToken, pingarthurBaseURL string, - pickStateAt func(time.Time) (int, state), - httpClient *http.Client, -) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - // Echo the sync-check token on every response — pingarthur's - // second monitor needs to validate the echo even on the 503 - // ("out of sync") path, or it'd record token_mismatch - // whenever sync-check is red, obscuring the real signal. - w.Header().Set("X-PingArthur-Token", syncEchoToken) - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - // X-Health-Status drives the atmosphere post body verbatim. We - // set a stable "in sync" string on the 200 path before any - // branching below; the 503 paths overwrite it with a short - // reason string. Stable text on the happy path means a healthy - // pingarthur produces ZERO atmosphere churn (same tuple every - // minute → no history-buffer entry → no post). Drift gets a - // distinct, human-readable reason instead of HTTP-status - // boilerplate. - w.Header().Set("X-Health-Status", "in sync") - - ctx, cancel := context.WithTimeout(r.Context(), syncCheckHTTPTimeout) - defer cancel() - - // fail overwrites the default "in sync" header with a short reason - // (becomes the atmosphere post text verbatim) and sends 503 with - // the longer explanation in the body. - fail := func(reason, detail string) { - w.Header().Set("X-Health-Status", reason) - http.Error(w, detail, http.StatusServiceUnavailable) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, pingarthurBaseURL+"/api/v1/status", nil) - if err != nil { - fail("sync-check build error", fmt.Sprintf("build request: %v\n", err)) - return - } - req.Header.Set("Authorization", "Bearer "+canaryEchoToken) - - resp, err := httpClient.Do(req) - if err != nil { - fail("pingarthur unreachable", fmt.Sprintf("pingarthur unreachable: %v\n", err)) - return - } - defer resp.Body.Close() - body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10)) - if resp.StatusCode != http.StatusOK { - fail(fmt.Sprintf("pingarthur returned %d", resp.StatusCode), - fmt.Sprintf("pingarthur returned %d: %s\n", resp.StatusCode, body)) - return - } - - var status struct { - Current struct { - HTTPStatus int `json:"http_status"` - HealthStatus string `json:"health_status"` - Error string `json:"error"` - ObservedAt time.Time `json:"observed_at"` - } `json:"current"` - } - if err := json.Unmarshal(body, &status); err != nil { - fail("parse error", fmt.Sprintf("parse status: %v\n", err)) - return - } - - // Staleness: observation older than 3 cycles means the checker - // has silently stopped. Fail fast. - if age := time.Since(status.Current.ObservedAt); age > syncCheckStaleAfter { - fail(fmt.Sprintf("observation stale (%v old)", age.Truncate(time.Second)), - fmt.Sprintf("observation stale: last at %v (%v ago)\n", - status.Current.ObservedAt, age.Truncate(time.Second))) - return - } - - // Compare the observation against what the canary SHOULD have - // been doing at that exact moment — not "right now", because - // the last probe could be up to one interval old. - _, expectedState := pickStateAt(status.Current.ObservedAt) - expected := expectedObservationFor(expectedState) - got := expectedObservation{ - HTTPStatus: status.Current.HTTPStatus, - HealthStatus: status.Current.HealthStatus, - Error: status.Current.Error, - } - if got != expected { - fail( - fmt.Sprintf("drift: got %d/%q/%q, expected %d/%q/%q", - got.HTTPStatus, trunc(got.HealthStatus, 24), got.Error, - expected.HTTPStatus, trunc(expected.HealthStatus, 24), expected.Error), - fmt.Sprintf( - "mismatch at %v:\n pingarthur: status=%d health=%q error=%q\n expected: status=%d health=%q error=%q\n", - status.Current.ObservedAt, - got.HTTPStatus, trunc(got.HealthStatus, 40), got.Error, - expected.HTTPStatus, trunc(expected.HealthStatus, 40), expected.Error, - )) - return - } - - _, _ = fmt.Fprintf(w, "in sync — pingarthur observation at %v matches expected state\n", status.Current.ObservedAt) - } -} - -func trunc(s string, n int) string { - if len(s) <= n { - return s - } - return s[:n] + "…" -} - -func buildMux( - echoToken, syncEchoToken, pingarthurBaseURL string, - pickState func() (int, state), - syncHTTPClient *http.Client, -) *http.ServeMux { - mux := http.NewServeMux() - - mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write([]byte("canary ok\n")) - }) - - mux.Handle("/expected-state", expectedStateHandler(pickState)) - - // /sync-check only registers when the sync-check credentials are - // configured — absent, it stays 404. Keeps the dev/local case simple. - if syncEchoToken != "" && pingarthurBaseURL != "" { - mux.Handle("/sync-check", syncCheckHandler( - echoToken, syncEchoToken, pingarthurBaseURL, - func(t time.Time) (int, state) { return currentState(t) }, - syncHTTPClient, - )) - } - - // Catch-all: the deliberate cycle behaviour. - mux.HandleFunc("/", cycleHandler(echoToken, func() state { - _, st := pickState() - return st - })) - - return mux -} - -func main() { - echoToken := os.Getenv("CANARY_ECHO_TOKEN") - if echoToken == "" { - log.Fatalf("CANARY_ECHO_TOKEN must be set (the pa_ bearer token bound into pingarthur for this URL)") - } - syncEchoToken := os.Getenv("SYNC_CHECK_ECHO_TOKEN") - pingarthurBaseURL := envOr("PINGARTHUR_BASE_URL", "https://pingarthur.com") - listenAddr := envOr("LISTEN_ADDR", "127.0.0.1:8091") - - syncClient := &http.Client{Timeout: syncCheckHTTPTimeout} - - mux := buildMux(echoToken, syncEchoToken, pingarthurBaseURL, - func() (int, state) { return currentState(time.Now()) }, - syncClient) - - srv := &http.Server{ - Addr: listenAddr, - Handler: mux, - ReadHeaderTimeout: 5 * time.Second, - // WriteTimeout must exceed state4SleepDuration; otherwise the - // server closes the connection before the handler finishes its - // sleep, which would surface as a different error category than - // we want (connection reset rather than client-side timeout). - WriteTimeout: state4SleepDuration + 5*time.Second, - } - - syncStatus := "disabled" - if syncEchoToken != "" && pingarthurBaseURL != "" { - syncStatus = "enabled (target: " + pingarthurBaseURL + ")" - } - log.Printf("canary listening on %s (cycle: %s; sync-check: %s)", listenAddr, cycleSummary(), syncStatus) - if err := srv.ListenAndServe(); err != nil { - log.Fatalf("listen: %v", err) - } -} - -func envOr(key, fallback string) string { - if v := os.Getenv(key); v != "" { - return v - } - return fallback -} - -func cycleSummary() string { - labels := make([]string, 0, cycleStates) - for _, st := range cycle { - switch { - case st.junkHeaders: - labels = append(labels, "junk-headers") - case st.sleepDuration > 0: - labels = append(labels, "timeout") - case !st.echoToken: - labels = append(labels, "no-token") - case st.status != 200: - labels = append(labels, fmt.Sprintf("%d", st.status)) - case len(st.healthStatus) > 50: - labels = append(labels, fmt.Sprintf("long-health(%d)", len(st.healthStatus))) - case st.healthStatus != "": - labels = append(labels, st.healthStatus) - default: - labels = append(labels, "ok") - } - } - return fmt.Sprintf("%d×1min: %s", cycleStates, strings.Join(labels, "→")) -} - -// keySuffix returns a short a-z pair distinct per i, enough for up to -// 26×26=676 unique header names. -func keySuffix(i int) string { - return string(rune('a'+(i%26))) + string(rune('a'+(i/26))) -} diff --git a/cmd/canary/main_test.go b/cmd/canary/main_test.go deleted file mode 100644 index 5c866dd..0000000 --- a/cmd/canary/main_test.go +++ /dev/null @@ -1,340 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" -) - -// TestCurrentState_Cycle locks the state selector to the wall-clock grid: -// consecutive minutes yield consecutive states; one full cycle wraps back. -func TestCurrentState_Cycle(t *testing.T) { - for i := range cycleStates { - t0 := time.Unix(int64(i*stateDurationSec), 0) - got, _ := currentState(t0) - if got != i { - t.Errorf("currentState(%v) = %d; want %d", t0, got, i) - } - } - wrap := time.Unix(int64(cycleStates*stateDurationSec), 0) - got, _ := currentState(wrap) - if got != 0 { - t.Errorf("wrap: currentState(%v) = %d; want 0", wrap, got) - } -} - -// stateFn returns a pickState-shaped closure that always picks cycle[i]. -// Keeps test boilerplate manageable now that buildMux takes the (int, state) -// variant everywhere. -func stateFn(i int) func() (int, state) { - return func() (int, state) { return i, cycle[i] } -} - -// TestCycleHandler_EmitsExpectedHeadersForEachState drives the cycle -// handler with each cycle state injected deterministically. Guarantees the -// observable tuple (status, health_status, token-echo presence) matches -// the cycle table. Skips state 4 — covered separately so the suite -// doesn't pay 12s per run. -func TestCycleHandler_EmitsExpectedHeadersForEachState(t *testing.T) { - const echoToken = "pa_test_canary_token" - - cases := []struct { - idx int - wantStatus int - wantHealth string - wantTokenEcho bool - }{ - {0, 200, "healthy", true}, - {1, 200, "degraded", true}, - {2, 503, "", true}, - {3, 200, "", false}, - {5, 200, strings.Repeat("a", state5LongHealthLen), true}, - } - for _, c := range cases { - t.Run("state-"+string(rune('0'+c.idx)), func(t *testing.T) { - h := cycleHandler(echoToken, func() state { return cycle[c.idx] }) - - rec := httptest.NewRecorder() - h.ServeHTTP(rec, httptest.NewRequest(http.MethodHead, "/", nil)) - - if rec.Code != c.wantStatus { - t.Errorf("status = %d; want %d", rec.Code, c.wantStatus) - } - if got := rec.Header().Get("X-Health-Status"); got != c.wantHealth { - t.Errorf("X-Health-Status length = %d, want = %d", len(got), len(c.wantHealth)) - } - got := rec.Header().Get("X-PingArthur-Token") - if c.wantTokenEcho && got != echoToken { - t.Errorf("X-PingArthur-Token = %q; want %q", got, echoToken) - } - if !c.wantTokenEcho && got != "" { - t.Errorf("X-PingArthur-Token = %q; want empty (mismatch state)", got) - } - }) - } -} - -// State 6 emits many junk headers until the aggregate size exceeds -// pingarthur's response-header cap. Count is exact; we don't verify the -// specific payload content (pingarthur doesn't record it anyway). -func TestCycleHandler_State6_EmitsJunkHeadersOverCap(t *testing.T) { - h := cycleHandler("pa_x", func() state { return cycle[6] }) - - rec := httptest.NewRecorder() - h.ServeHTTP(rec, httptest.NewRequest(http.MethodHead, "/", nil)) - - junkKeys := 0 - for k := range rec.Header() { - if strings.HasPrefix(k, "X-Junk-") { - junkKeys++ - } - } - if junkKeys != state6JunkHeaderCount { - t.Errorf("junk header count = %d; want %d", junkKeys, state6JunkHeaderCount) - } - // State 6 still echoes the token + returns 200 — the breakage is - // in the header-size cap, not the observable tuple. - if got := rec.Header().Get("X-PingArthur-Token"); got == "" { - t.Error("state 6 must still echo token (the breakage is the header-flood, not a token mismatch)") - } -} - -// /healthz bypasses the cycle and is always 200 "canary ok\n". -func TestMux_HealthzAlwaysOK(t *testing.T) { - mux := buildMux("pa_x", "", "", stateFn(4), nil) - - rec := httptest.NewRecorder() - mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) - - if rec.Code != 200 { - t.Errorf("/healthz status = %d; want 200", rec.Code) - } - if rec.Body.String() != "canary ok\n" { - t.Errorf("/healthz body = %q; want %q", rec.Body.String(), "canary ok\n") - } -} - -// State 4's sleep must honour the request context — if the client -// cancels mid-sleep, the handler should return promptly instead of -// blocking ~12 seconds. Uses a tiny injected sleepDuration so the -// "didn't honour cancel" failure fails fast too. -func TestCycleHandler_SleepRespectsClientCancellation(t *testing.T) { - injected := state{status: 200, echoToken: true, sleepDuration: 10 * time.Second} - h := cycleHandler("pa_x", func() state { return injected }) - - ctx, cancel := context.WithCancel(context.Background()) - cancel() // cancel before the request is served - - req := httptest.NewRequest(http.MethodHead, "/", nil).WithContext(ctx) - rec := httptest.NewRecorder() - - done := make(chan struct{}) - go func() { - h.ServeHTTP(rec, req) - close(done) - }() - select { - case <-done: - // ok - case <-time.After(250 * time.Millisecond): - t.Fatal("handler did not honour cancelled context within 250ms") - } -} - -// TestExpectedStateHandler_ReturnsCurrentCycleJSON sanity-checks the -// diagnostic /expected-state endpoint: the JSON reflects what the -// pickState closure says the current state is, with tuple fields -// pre-massaged to match what pingarthur would record (in particular, -// state 5's long health_status is rune-capped to pingarthurHealthStatusMax already). -func TestExpectedStateHandler_ReturnsCurrentCycleJSON(t *testing.T) { - cases := []struct { - idx int - wantStat int - wantErr string - wantHLen int - }{ - {0, 200, "", len("healthy")}, - {2, 503, "", 0}, - {3, 200, "token_mismatch", 0}, - {4, 0, "timeout", 0}, - {5, 200, "", pingarthurHealthStatusMax}, // truncated - {6, 0, "other", 0}, - } - for _, c := range cases { - t.Run(string(rune('0'+c.idx)), func(t *testing.T) { - h := expectedStateHandler(stateFn(c.idx)) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/expected-state", nil)) - - var resp struct { - CycleIndex int `json:"cycle_index"` - Expected expectedObservation `json:"expected"` - } - if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { - t.Fatalf("decode: %v", err) - } - if resp.CycleIndex != c.idx { - t.Errorf("CycleIndex = %d; want %d", resp.CycleIndex, c.idx) - } - if resp.Expected.HTTPStatus != c.wantStat { - t.Errorf("HTTPStatus = %d; want %d", resp.Expected.HTTPStatus, c.wantStat) - } - if resp.Expected.Error != c.wantErr { - t.Errorf("Error = %q; want %q", resp.Expected.Error, c.wantErr) - } - if len(resp.Expected.HealthStatus) != c.wantHLen { - t.Errorf("HealthStatus length = %d; want %d", len(resp.Expected.HealthStatus), c.wantHLen) - } - }) - } -} - -// TestSyncCheckHandler_InSync: pingarthur's /api/v1/status reports an -// observation matching the expected state for that observed_at → 200. -func TestSyncCheckHandler_InSync(t *testing.T) { - const syncEcho = "pa_sync_token" - const canaryEcho = "pa_canary_token" - - observedAt := time.Unix(1776680000, 0).UTC() // Unix/60 % 7 = ... - idx := int(observedAt.Unix()/stateDurationSec) % cycleStates - expected := expectedObservationFor(cycle[idx]) - - // Fake pingarthur: returns a /api/v1/status payload that matches - // what the canary says was expected at observedAt. - pingarthur := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("Authorization"); got != "Bearer "+canaryEcho { - t.Errorf("Authorization = %q; want %q", got, "Bearer "+canaryEcho) - } - payload := map[string]any{ - "url": "https://canary.pingarthur.com/", - "current": map[string]any{ - "http_status": expected.HTTPStatus, - "health_status": expected.HealthStatus, - "error": expected.Error, - "observed_at": observedAt.Format(time.RFC3339Nano), - }, - "history": []any{}, - } - _ = json.NewEncoder(w).Encode(payload) - })) - defer pingarthur.Close() - - // Pin canary's state-at-time to cycle[idx] so the comparison resolves - // to the same tuple we fed back through the fake pingarthur. - pickAt := func(time.Time) (int, state) { return idx, cycle[idx] } - - // Override syncCheckStaleAfter? No — we made observedAt = Unix time - // around 1776680000 which is in the past. Instead inject observedAt - // = time.Now() so staleness doesn't trip. Reshape: - observedAt = time.Now().UTC().Truncate(time.Second) - idx = int(observedAt.Unix()/stateDurationSec) % cycleStates - expected = expectedObservationFor(cycle[idx]) - pickAt = func(time.Time) (int, state) { return idx, cycle[idx] } - - pingarthur.Close() - pingarthur = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - payload := map[string]any{ - "url": "https://canary.pingarthur.com/", - "current": map[string]any{ - "http_status": expected.HTTPStatus, - "health_status": expected.HealthStatus, - "error": expected.Error, - "observed_at": observedAt.Format(time.RFC3339Nano), - }, - "history": []any{}, - } - _ = json.NewEncoder(w).Encode(payload) - })) - defer pingarthur.Close() - - h := syncCheckHandler(canaryEcho, syncEcho, pingarthur.URL, pickAt, pingarthur.Client()) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/sync-check", nil)) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d (%s); want 200", rec.Code, rec.Body.String()) - } - if got := rec.Header().Get("X-PingArthur-Token"); got != syncEcho { - t.Errorf("X-PingArthur-Token = %q; want %q", got, syncEcho) - } -} - -// TestSyncCheckHandler_Mismatch: pingarthur reports a different tuple -// than expected → 503 with a diff body. Token still echoed so pingarthur's -// second monitor records the 503 rather than token_mismatch. -func TestSyncCheckHandler_Mismatch(t *testing.T) { - const syncEcho = "pa_sync_token" - - observedAt := time.Now().UTC().Truncate(time.Second) - idx := int(observedAt.Unix()/stateDurationSec) % cycleStates - wrongIdx := (idx + 1) % cycleStates - // Build a payload that matches wrongIdx, not idx. - wrong := expectedObservationFor(cycle[wrongIdx]) - - pingarthur := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _ = json.NewEncoder(w).Encode(map[string]any{ - "url": "https://canary.pingarthur.com/", - "current": map[string]any{ - "http_status": wrong.HTTPStatus, - "health_status": wrong.HealthStatus, - "error": wrong.Error, - "observed_at": observedAt.Format(time.RFC3339Nano), - }, - "history": []any{}, - }) - })) - defer pingarthur.Close() - - pickAt := func(time.Time) (int, state) { return idx, cycle[idx] } - h := syncCheckHandler("pa_c", syncEcho, pingarthur.URL, pickAt, pingarthur.Client()) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/sync-check", nil)) - - if rec.Code != http.StatusServiceUnavailable { - t.Fatalf("status = %d (%s); want 503", rec.Code, rec.Body.String()) - } - if got := rec.Header().Get("X-PingArthur-Token"); got != syncEcho { - t.Errorf("X-PingArthur-Token on 503 = %q; want %q (must still echo so pingarthur observes the 503 in-band)", got, syncEcho) - } - if !strings.Contains(rec.Body.String(), "mismatch at") { - t.Errorf("body missing 'mismatch at': %q", rec.Body.String()) - } -} - -// TestSyncCheckHandler_Stale: if pingarthur's observed_at is older than -// syncCheckStaleAfter, sync-check fails regardless of tuple match. -func TestSyncCheckHandler_Stale(t *testing.T) { - stale := time.Now().Add(-syncCheckStaleAfter - time.Minute) - idx := int(stale.Unix()/stateDurationSec) % cycleStates - expected := expectedObservationFor(cycle[idx]) - - pingarthur := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _ = json.NewEncoder(w).Encode(map[string]any{ - "url": "https://canary.pingarthur.com/", - "current": map[string]any{ - "http_status": expected.HTTPStatus, - "health_status": expected.HealthStatus, - "error": expected.Error, - "observed_at": stale.UTC().Format(time.RFC3339Nano), - }, - "history": []any{}, - }) - })) - defer pingarthur.Close() - - pickAt := func(time.Time) (int, state) { return idx, cycle[idx] } - h := syncCheckHandler("pa_c", "pa_s", pingarthur.URL, pickAt, pingarthur.Client()) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/sync-check", nil)) - - if rec.Code != http.StatusServiceUnavailable { - t.Fatalf("status = %d; want 503 on stale observation", rec.Code) - } - if !strings.Contains(rec.Body.String(), "stale") { - t.Errorf("body missing 'stale': %q", rec.Body.String()) - } -} diff --git a/dev_mode.go b/dev_mode.go deleted file mode 100644 index 96224bd..0000000 --- a/dev_mode.go +++ /dev/null @@ -1,11 +0,0 @@ -//go:build !dev - -package main - -// insecureDevMode is wired to a stub that always returns false in production -// builds. The real implementation lives in dev_mode_dev.go and only compiles -// when `-tags dev` is passed — so a release binary can't accidentally relax -// the SSRF / TLS-trust gates even if INSECURE_DEV_MODE is set in its env. -// -// See dev_mode_dev.go for the dev path; CLAUDE.md "Dev mode" for context. -func insecureDevMode() bool { return false } diff --git a/dev_mode_dev.go b/dev_mode_dev.go deleted file mode 100644 index dd2ee6f..0000000 --- a/dev_mode_dev.go +++ /dev/null @@ -1,17 +0,0 @@ -//go:build dev - -package main - -import "os" - -// insecureDevMode reports whether dev-only safety relaxations are active. -// Two conditions must both be true: -// 1. The binary was built with `-tags dev` (this file compiled in) -// 2. The env var INSECURE_DEV_MODE=true at runtime -// -// When active, three production gates are relaxed (verify.go::prepareURL, -// safehttp.go::isBlockedIP, safehttp.go::newSafeHTTPClient TLSConfig). Used -// for the local Procfile and the e2e test, never in production. -func insecureDevMode() bool { - return os.Getenv("INSECURE_DEV_MODE") == "true" -} diff --git a/e2e_test.go b/e2e_test.go deleted file mode 100644 index d66305e..0000000 --- a/e2e_test.go +++ /dev/null @@ -1,146 +0,0 @@ -//go:build dev - -package main - -import ( - "bytes" - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "testing" - "time" - - clientv3 "go.etcd.io/etcd/client/v3" -) - -// TestEndToEnd_FullPipeline drives the production HTTP handlers + verifier + -// checker against a real local TLS server. With -tags dev and -// INSECURE_DEV_MODE=true (set by t.Setenv below), the SSRF + TLS-trust gates -// relax exactly far enough to let httptest binaries reach each other. -// -// What's tested end-to-end: -// POST /api/v1/tokens → real handler -// POST /api/v1/monitors → real handler + real verifyURL + real -// HEAD over TCP to the target -// manual scanDue trigger → real checker + real checkURL -// GET /api/v1/status → real handler -// -// Everything in between (etcd CAS, observation tuple, history, etc) goes -// through the production code path, not test fakes. -func TestEndToEnd_FullPipeline(t *testing.T) { - t.Setenv("INSECURE_DEV_MODE", "true") - - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - - httpClient := newSafeHTTPClient() - verify := func(ctx context.Context, url, token string) (bool, observation) { - return verifyURL(ctx, httpClient, url, token) - } - check := func(ctx context.Context, url, expectedTokHash string) (bool, observation) { - return checkURL(ctx, httpClient, secret, url, expectedTokHash) - } - - mux := http.NewServeMux() - mux.Handle("POST /api/v1/tokens", tokensHandler(secret)) - mux.Handle("POST /api/v1/monitors", bindMonitorHandler(context.Background(), secret, cli, verify, 100, nil, nil)) - mux.Handle("GET /api/v1/status", statusHandler(secret, cli)) - apiSrv := httptest.NewServer(limitBody(maxRequestBodyBytes, mux)) - defer apiSrv.Close() - - // The monitored target. Echoes whatever bind token we capture below - // so verifyURL (bind path) AND checkURL (checker path) both succeed. - var boundToken string - target := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - if boundToken != "" { - w.Header().Set("X-PingArthur-Token", boundToken) - } - w.Header().Set("X-Health-Status", "healthy") - w.WriteHeader(http.StatusOK) - })) - defer target.Close() - - // 1. Mint token - tokResp, err := http.Post(apiSrv.URL+"/api/v1/tokens", "application/json", nil) - if err != nil { - t.Fatalf("POST /api/v1/tokens: %v", err) - } - var tokBody struct { - Token string `json:"token"` - ExpiresAt time.Time `json:"expires_at"` - } - if err := json.NewDecoder(tokResp.Body).Decode(&tokBody); err != nil { - t.Fatalf("decode token: %v", err) - } - tokResp.Body.Close() - if tokBody.Token == "" { - t.Fatal("token empty") - } - boundToken = tokBody.Token - - // 2. Bind monitor (real HEAD goes out to target.URL) - bindBody, _ := json.Marshal(map[string]string{"url": target.URL}) - bindReq, _ := http.NewRequest(http.MethodPost, apiSrv.URL+"/api/v1/monitors", bytes.NewReader(bindBody)) - bindReq.Header.Set("Authorization", "Bearer "+tokBody.Token) - bindReq.Header.Set("Content-Type", "application/json") - bindResp, err := http.DefaultClient.Do(bindReq) - if err != nil { - t.Fatalf("POST /api/v1/monitors: %v", err) - } - if bindResp.StatusCode != http.StatusCreated { - body, _ := io.ReadAll(bindResp.Body) - bindResp.Body.Close() - t.Fatalf("bind status = %d: %s", bindResp.StatusCode, body) - } - bindResp.Body.Close() - - // 3. Force a check cycle now (skip the 60min jitter wait). Backdated - // due/ entry → scanDue picks it up → real checkURL → real HEAD. - tokHash := tokenHash(tokBody.Token) - if _, err := cli.Delete(context.Background(), keyPrefixDue, clientv3.WithPrefix()); err != nil { - t.Fatalf("clear due: %v", err) - } - if _, err := cli.Put(context.Background(), dueKey(time.Now().Add(-time.Minute), tokHash), ""); err != nil { - t.Fatalf("put backdated due: %v", err) - } - scanDue(context.Background(), cli, check, nil) - - // 4. Poll status — observation must reflect the target's HEAD response - statusReq, _ := http.NewRequest(http.MethodGet, apiSrv.URL+"/api/v1/status", nil) - statusReq.Header.Set("Authorization", "Bearer "+tokBody.Token) - statusResp, err := http.DefaultClient.Do(statusReq) - if err != nil { - t.Fatalf("GET /api/v1/status: %v", err) - } - if statusResp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(statusResp.Body) - statusResp.Body.Close() - t.Fatalf("status = %d: %s", statusResp.StatusCode, body) - } - var sb struct { - URL string `json:"url"` - Current observation `json:"current"` - } - if err := json.NewDecoder(statusResp.Body).Decode(&sb); err != nil { - t.Fatalf("decode status: %v", err) - } - statusResp.Body.Close() - - if sb.URL != target.URL { - t.Errorf("status url = %q; want %q", sb.URL, target.URL) - } - if sb.Current.HTTPStatus != 200 { - t.Errorf("current.http_status = %d; want 200", sb.Current.HTTPStatus) - } - if sb.Current.HealthStatus != "healthy" { - t.Errorf("current.health_status = %q; want healthy", sb.Current.HealthStatus) - } - if sb.Current.Error != "" { - t.Errorf("current.error = %q; want empty (token echoed correctly)", sb.Current.Error) - } - if sb.Current.ObservedAt.IsZero() { - t.Error("current.observed_at is zero") - } -} diff --git a/etcd.go b/etcd.go deleted file mode 100644 index 354349a..0000000 --- a/etcd.go +++ /dev/null @@ -1,48 +0,0 @@ -package main - -import ( - "fmt" - "time" - - clientv3 "go.etcd.io/etcd/client/v3" -) - -// Keyspace — the schema is the key layout. See CLAUDE.md "Data model". -const ( - keyPrefixMonitor = "monitor/" - keyPrefixDue = "due/" - keyPrefixETLD = "etld/" - keyPrefixETLDCount = "etld_count/" - keyPrefixBindPending = "bind_pending/" - keyPrefixClaimed = "claimed/" - keyStatsCount = "stats/monitor_count" - - // 20 digits covers the full int64 unix range so lexicographic order - // always matches chronological order on the due/ prefix. - dueTimestampDigits = 20 -) - -func monitorKey(tokenHash string) string { return keyPrefixMonitor + tokenHash } -func claimedKey(tokenHash string) string { return keyPrefixClaimed + tokenHash } -func bindPendingKey(tokenHash string) string { return keyPrefixBindPending + tokenHash } -func etldCountKey(etld1 string) string { return keyPrefixETLDCount + etld1 } -func etldKey(etld1, tokenHash string) string { - return keyPrefixETLD + etld1 + "/" + tokenHash -} - -// dueKey returns "due/<20-digit-unix>/". Padding makes the prefix -// scan ordered by time, so the checker finds the oldest due entries via a -// single ordered range scan. -func dueKey(t time.Time, tokenHash string) string { - return fmt.Sprintf("%s%0*d/%s", keyPrefixDue, dueTimestampDigits, t.Unix(), tokenHash) -} - -// newEtcdClient dials the cluster. In prod each node points at its own local -// member (127.0.0.1:2379); multiple endpoints enable client-side failover -// via clientv3's built-in balancer for non-standard local-dev setups. -func newEtcdClient(endpoints []string) (*clientv3.Client, error) { - return clientv3.New(clientv3.Config{ - Endpoints: endpoints, - DialTimeout: 3 * time.Second, - }) -} diff --git a/etcd_test.go b/etcd_test.go deleted file mode 100644 index b86d96e..0000000 --- a/etcd_test.go +++ /dev/null @@ -1,139 +0,0 @@ -package main - -import ( - "context" - "fmt" - "net" - "net/url" - "testing" - "time" - - clientv3 "go.etcd.io/etcd/client/v3" - "go.etcd.io/etcd/server/v3/embed" -) - -// freePort reserves a TCP port on 127.0.0.1 by listening and closing. There's -// a TOCTOU gap between close and etcd rebinding, but it's the standard pattern -// and fine for single-machine tests. -func freePort(t *testing.T) int { - t.Helper() - l, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen: %v", err) - } - port := l.Addr().(*net.TCPAddr).Port - l.Close() - return port -} - -// startEmbeddedEtcd spins up a single-node etcd in-process and returns a -// connected client. Cleans up on test end. Integration tests use this instead -// of mocks — embed *is* the real etcd server. -func startEmbeddedEtcd(t *testing.T) *clientv3.Client { - t.Helper() - - clientPort := freePort(t) - peerPort := freePort(t) - clientURL, _ := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", clientPort)) - peerURL, _ := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", peerPort)) - - cfg := embed.NewConfig() - cfg.Dir = t.TempDir() - cfg.LogLevel = "error" - cfg.ListenClientUrls = []url.URL{*clientURL} - cfg.AdvertiseClientUrls = []url.URL{*clientURL} - cfg.ListenPeerUrls = []url.URL{*peerURL} - cfg.AdvertisePeerUrls = []url.URL{*peerURL} - cfg.InitialCluster = fmt.Sprintf("%s=%s", cfg.Name, peerURL.String()) - - e, err := embed.StartEtcd(cfg) - if err != nil { - t.Fatalf("embed.StartEtcd: %v", err) - } - t.Cleanup(func() { e.Close() }) - - select { - case <-e.Server.ReadyNotify(): - case <-time.After(10 * time.Second): - t.Fatal("embedded etcd not ready within 10s") - } - - cli, err := newEtcdClient([]string{clientURL.Host}) - if err != nil { - t.Fatalf("newEtcdClient: %v", err) - } - t.Cleanup(func() { cli.Close() }) - return cli -} - -func TestEtcd_CASRoundTrip(t *testing.T) { - cli := startEmbeddedEtcd(t) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - // First CAS: create-if-absent (CreateRevision == 0 means key does not exist). - resp, err := cli.Txn(ctx). - If(clientv3.Compare(clientv3.CreateRevision(keyStatsCount), "=", 0)). - Then(clientv3.OpPut(keyStatsCount, "1")). - Commit() - if err != nil { - t.Fatalf("create-if-absent txn: %v", err) - } - if !resp.Succeeded { - t.Fatal("create-if-absent should have succeeded on empty keyspace") - } - - getResp, err := cli.Get(ctx, keyStatsCount) - if err != nil { - t.Fatalf("get: %v", err) - } - if len(getResp.Kvs) != 1 || string(getResp.Kvs[0].Value) != "1" { - t.Fatalf("%s = %v; want [1]", keyStatsCount, getResp.Kvs) - } - modRev := getResp.Kvs[0].ModRevision - - // CAS on current ModRevision: should succeed. - resp, err = cli.Txn(ctx). - If(clientv3.Compare(clientv3.ModRevision(keyStatsCount), "=", modRev)). - Then(clientv3.OpPut(keyStatsCount, "2")). - Commit() - if err != nil { - t.Fatalf("CAS update: %v", err) - } - if !resp.Succeeded { - t.Fatal("CAS with current ModRevision should have succeeded") - } - - // CAS on stale ModRevision: should fail — this is the contention-detection - // primitive the bind and auto-stop txns will rely on. - resp, err = cli.Txn(ctx). - If(clientv3.Compare(clientv3.ModRevision(keyStatsCount), "=", modRev)). - Then(clientv3.OpPut(keyStatsCount, "3")). - Commit() - if err != nil { - t.Fatalf("stale CAS: %v", err) - } - if resp.Succeeded { - t.Fatal("CAS with stale ModRevision should have failed") - } -} - -func TestDueKey_LexicographicOrdering(t *testing.T) { - cases := []struct { - t time.Time - tokHash string - }{ - {time.Unix(1, 0), "aaa"}, - {time.Unix(100, 0), "zzz"}, - {time.Unix(1_000_000_000, 0), "aaa"}, - {time.Unix(2_000_000_000, 0), "aaa"}, - } - var prev string - for i, c := range cases { - k := dueKey(c.t, c.tokHash) - if i > 0 && !(prev < k) { - t.Fatalf("dueKey not ordered: %q !< %q", prev, k) - } - prev = k - } -} diff --git a/go.mod b/go.mod deleted file mode 100644 index 0aa560f..0000000 --- a/go.mod +++ /dev/null @@ -1,89 +0,0 @@ -module tangled.sh/divepool.social/pingarthur - -go 1.25.1 - -require ( - github.com/getkin/kin-openapi v0.133.0 - github.com/oapi-codegen/oapi-codegen/v2 v2.6.0 - github.com/prometheus/client_golang v1.23.2 - go.etcd.io/etcd/client/v3 v3.6.10 - go.etcd.io/etcd/server/v3 v3.6.10 - golang.org/x/net v0.53.0 -) - -require ( - github.com/beorn7/perks v1.0.1 // indirect - github.com/cenkalti/backoff/v4 v4.3.0 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/coreos/go-semver v0.3.1 // indirect - github.com/coreos/go-systemd/v22 v22.5.0 // indirect - github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/swag v0.23.0 // indirect - github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang-jwt/jwt/v5 v5.2.2 // indirect - github.com/golang/protobuf v1.5.4 // indirect - github.com/google/btree v1.1.3 // indirect - github.com/google/go-cmp v0.7.0 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/websocket v1.4.2 // indirect - github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1 // indirect - github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect - github.com/jonboulle/clockwork v0.5.0 // indirect - github.com/josharian/intern v1.0.0 // indirect - github.com/kylelemons/godebug v1.1.0 // indirect - github.com/mailru/easyjson v0.7.7 // indirect - github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect - github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect - github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect - github.com/perimeterx/marshmallow v1.1.5 // indirect - github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.66.1 // indirect - github.com/prometheus/procfs v0.16.1 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect - github.com/soheilhy/cmux v0.1.5 // indirect - github.com/speakeasy-api/jsonpath v0.6.0 // indirect - github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect - github.com/spf13/pflag v1.0.10 // indirect - github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802 // indirect - github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect - github.com/woodsbury/decimal128 v1.3.0 // indirect - github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 // indirect - go.etcd.io/bbolt v1.4.3 // indirect - go.etcd.io/etcd/api/v3 v3.6.10 // indirect - go.etcd.io/etcd/client/pkg/v3 v3.6.10 // indirect - go.etcd.io/etcd/pkg/v3 v3.6.10 // indirect - go.etcd.io/raft/v3 v3.6.0 // indirect - go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 // indirect - go.opentelemetry.io/otel v1.40.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 // indirect - go.opentelemetry.io/otel/metric v1.40.0 // indirect - go.opentelemetry.io/otel/sdk v1.40.0 // indirect - go.opentelemetry.io/otel/trace v1.40.0 // indirect - go.opentelemetry.io/proto/otlp v1.5.0 // indirect - go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect - go.yaml.in/yaml/v2 v2.4.2 // indirect - golang.org/x/crypto v0.50.0 // indirect - golang.org/x/mod v0.34.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/text v0.36.0 // indirect - golang.org/x/time v0.9.0 // indirect - golang.org/x/tools v0.43.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect - google.golang.org/grpc v1.79.3 // indirect - google.golang.org/protobuf v1.36.10 // indirect - gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect - sigs.k8s.io/json v0.0.0-20211020170558-c049b76a60c6 // indirect - sigs.k8s.io/yaml v1.4.0 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index d2c09e1..0000000 --- a/go.sum +++ /dev/null @@ -1,313 +0,0 @@ -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/cockroachdb/datadriven v1.0.2 h1:H9MtNqVoVhvd9nCBwOyDjUEdZCREqbIdCJD93PBm/jA= -github.com/cockroachdb/datadriven v1.0.2/go.mod h1:a9RdTaap04u637JoCzcUoIcDmvwSUtcUFtT/C3kJlTU= -github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= -github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= -github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= -github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= -github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= -github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= -github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= -github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= -github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= -github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= -github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= -github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= -github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1 h1:qnpSQwGEnkcRpTqNOIR6bJbR0gAorgP9CSALpRcKoAA= -github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1/go.mod h1:lXGCsh6c22WGtjr+qGHj1otzZpV/1kwTMAqkwZsnWRU= -github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0 h1:pRhl55Yx1eC7BZ1N+BBWwnKaMyD8uC+34TLdndZMAKk= -github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0/go.mod h1:XKMd7iuf/RGPSMJ/U4HP0zS2Z9Fh8Ps9a+6X26m/tmI= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= -github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= -github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= -github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= -github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= -github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= -github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= -github.com/oapi-codegen/oapi-codegen/v2 v2.6.0 h1:4i+F2cvwBFZeplxCssNdLy3MhNzUD87mI3HnayHZkAU= -github.com/oapi-codegen/oapi-codegen/v2 v2.6.0/go.mod h1:eWHeJSohQJIINJZzzQriVynfGsnlQVh0UkN2UYYcw4Q= -github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= -github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= -github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= -github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= -github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= -github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= -github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= -github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= -github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= -github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= -github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= -github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= -github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= -github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= -github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= -github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= -github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= -github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= -github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= -github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js= -github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= -github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g8DHMXJ8= -github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw= -github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU= -github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg= -github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= -github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802 h1:uruHq4dN7GR16kFc5fp3d1RIYzJW5onx8Ybykw2YQFA= -github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= -github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= -github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= -github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= -github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= -github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= -github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 h1:eY9dn8+vbi4tKz5Qo6v2eYzo7kUS51QINcR5jNpbZS8= -github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= -go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= -go.etcd.io/etcd/api/v3 v3.6.10 h1:jlwjtELjA8yi2VWpOFH+0w0lGr3K6mVDyn0RDB9aaAY= -go.etcd.io/etcd/api/v3 v3.6.10/go.mod h1:pdV4VeFmvhdNjB4LWRkC8ReLyRBAxUOze3GarMhE2sk= -go.etcd.io/etcd/client/pkg/v3 v3.6.10 h1:tBT7podcPhuVbCVkAEzx8bC5I+aqxfLwBN8/As1arrA= -go.etcd.io/etcd/client/pkg/v3 v3.6.10/go.mod h1:WEy3PpwbbEBVRdh1NVJYsuUe/8eyI21PNJRazeD8z/Y= -go.etcd.io/etcd/client/v3 v3.6.10 h1:J598zJ+C/ZPvImypmq5waj84+bovePrlZERHklf34y0= -go.etcd.io/etcd/client/v3 v3.6.10/go.mod h1:iHhUDUcEwaKs1YFq3MgmI9U4zhTVasp/vgdVbFf1RS8= -go.etcd.io/etcd/pkg/v3 v3.6.10 h1:17TwO17SkbgIBCloGRikqVy/HdPbeXX4zPAlzRZbzDw= -go.etcd.io/etcd/pkg/v3 v3.6.10/go.mod h1:UfjvwrKPZVnmpKD3UZKQpC7kMLsSIGlQjIVFGRXI6eo= -go.etcd.io/etcd/server/v3 v3.6.10 h1:MfnewdUHfYUr7ytYKuYQb2WWxTstGj70Vx7nM8e2rVs= -go.etcd.io/etcd/server/v3 v3.6.10/go.mod h1:dWedTPxlXqd9P4pxgqjSb2NwcqobmumsMWLOf4Z+OTY= -go.etcd.io/raft/v3 v3.6.0 h1:5NtvbDVYpnfZWcIHgGRk9DyzkBIXOi8j+DDp1IcnUWQ= -go.etcd.io/raft/v3 v3.6.0/go.mod h1:nLvLevg6+xrVtHUmVaTcTz603gQPHfh7kUAwV6YpfGo= -go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= -go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 h1:rgMkmiGfix9vFJDcDi1PK8WEQP4FLQwLDfhp5ZLpFeE= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0/go.mod h1:ijPqXp5P6IRRByFVVg9DY8P5HkxkHE5ARIa+86aXPf4= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 h1:tgJ0uaNS4c98WRNUEx5U3aDlrDOI5Rs+1Vifcw4DJ8U= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= -go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= -go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= -golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= -golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= -golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= -gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -sigs.k8s.io/json v0.0.0-20211020170558-c049b76a60c6 h1:fD1pz4yfdADVNfFmcP2aBEtudwUQ1AlLnRBALr33v3s= -sigs.k8s.io/json v0.0.0-20211020170558-c049b76a60c6/go.mod h1:p4QtZmO4uMYipTQNzagwnNoseA6OxSUutVw05NhYDRs= -sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= -sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/homepage.go b/homepage.go deleted file mode 100644 index ea7fe36..0000000 --- a/homepage.go +++ /dev/null @@ -1,78 +0,0 @@ -package main - -import ( - "io" - "net/http" -) - -// homepageHTML is served at GET /. Deliberately tiny — pingarthur is an -// API service, the homepage exists so a human (or a coding agent) landing -// on the bare domain can find the spec, the source, and the legal docs. -// -// Legal redirects point at divepool.social rather than introducing a -// pingarthur-specific set: same legal entity behind both services, one -// shared set of Termly policies that names both. -const homepageHTML = ` - - - - -PingArthur - - - - - - - - - - - - - - - - - - -

PingArthur

-

A divepool service.

-

LLM-first uptime monitoring. Point your coding agent at the OpenAPI spec to start monitoring:

-

https://pingarthur.com/api/v1/openapi

-

Source: tangled.org/divepool.social/pingarthur (MIT, self-hostable).

- - -` - -func serveHomepage(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Header().Set("Cache-Control", "public, max-age=300") - _, _ = io.WriteString(w, homepageHTML) -} diff --git a/homepage_test.go b/homepage_test.go deleted file mode 100644 index 6660d78..0000000 --- a/homepage_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package main - -import ( - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -// TestHomepage_ServesAndLinks asserts the homepage renders and contains the -// links a landing visitor (or coding agent) needs: spec, source, legal. -// Brittle by design — these are public commitments, breaking any of them -// silently would be bad. -func TestHomepage_ServesAndLinks(t *testing.T) { - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/", nil) - serveHomepage(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", rec.Code) - } - if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { - t.Errorf("Content-Type = %q, want text/html", ct) - } - body := rec.Body.String() - wants := []string{ - "/api/v1/openapi", // spec link - "https://tangled.org/divepool.social/pingarthur", // source link - "https://divepool.social/", // operator site (umbrella attribution) - "https://divepool.social/privacy", // legal: privacy - "https://divepool.social/terms", // legal: terms - "https://divepool.social/imprint", // legal: imprint - } - for _, w := range wants { - if !strings.Contains(body, w) { - t.Errorf("homepage missing %q", w) - } - } -} - -// TestHomepage_OnlyMatchesRoot guards against the `{$}` anchor regressing — -// without it the root handler would catch every path, swallowing 404s on -// unknown routes and shadowing future additions. -func TestHomepage_OnlyMatchesRoot(t *testing.T) { - mux := http.NewServeMux() - mux.Handle("GET /{$}", http.HandlerFunc(serveHomepage)) - srv := httptest.NewServer(mux) - t.Cleanup(srv.Close) - - cases := map[string]int{ - "/": http.StatusOK, - "/nope": http.StatusNotFound, - "/api/v1": http.StatusNotFound, - "/healthz": http.StatusNotFound, - } - for path, want := range cases { - resp, err := http.Get(srv.URL + path) - if err != nil { - t.Fatalf("GET %s: %v", path, err) - } - resp.Body.Close() - if resp.StatusCode != want { - t.Errorf("GET %s: status = %d, want %d", path, resp.StatusCode, want) - } - } -} diff --git a/main.go b/main.go deleted file mode 100644 index fc7d5c4..0000000 --- a/main.go +++ /dev/null @@ -1,330 +0,0 @@ -package main - -import ( - "context" - "encoding/base64" - "encoding/hex" - "encoding/json" - "fmt" - "log" - "net/http" - "os" - "os/signal" - "strconv" - "strings" - "syscall" - "time" - - "github.com/prometheus/client_golang/prometheus/promhttp" -) - -// HTTP-server-side limits. The X-Health-Status truncation in verify.go is -// independent — targets can send a longer header, we only persist the first -// healthStatusMax bytes. See openapi.json for the canonical spec. -const ( - // serverTimeout bounds pure client-side transfer phases: reading the - // request, keep-alive idle, and header read. Setting it equal to the - // outbound HEAD probe's budget is fine for read/idle but *not* for - // write — the bind handler's response can't be sent until verify - // returns, and verify can itself take the full 10s. Keeping - // WriteTimeout equal meant slow targets surfaced as 502 EOF to - // clients instead of a proper 4xx; see serverWriteTimeout. - serverTimeout = 10 * time.Second - // serverWriteTimeout has to be strictly greater than the upstream - // HEAD probe deadline so there's headroom to write a response after - // a timed-out verify (and to do the rollback txn in between). - serverWriteTimeout = 15 * time.Second - maxRequestBodyBytes = 4 << 10 // 4 KiB — /tokens has no body, /monitors expects {"url": ...} - maxRequestHeaderBytes = 16 << 10 // 16 KiB — well above any sane bearer token + browser headers -) - -type config struct { - listenAddr string - metricsAddr string - tokenHMACSecret []byte - atmosphereKey []byte - etcdEndpoints []string - maxMonitors int64 -} - -func loadConfig() config { - secret, err := parseSecret(mustEnv("TOKEN_HMAC_SECRET")) - if err != nil { - log.Fatalf("TOKEN_HMAC_SECRET: %v", err) - } - // ATMOSPHERE_ENC_KEY must decode to exactly 32 bytes. Loss/rotation - // renders every stored app password unrecoverable — treat as - // once-forever. Required even if no monitor currently has an atmosphere - // binding, so operators don't discover it missing on first bind. - atmoKey, err := parseAtmosphereKey(mustEnv("ATMOSPHERE_ENC_KEY")) - if err != nil { - log.Fatalf("ATMOSPHERE_ENC_KEY: %v", err) - } - maxMon, err := parseMaxMonitors(envOr("MAX_MONITORS", strconv.FormatInt(defaultMaxMonitors, 10))) - if err != nil { - log.Fatalf("MAX_MONITORS: %v", err) - } - return config{ - listenAddr: envOr("LISTEN_ADDR", ":8080"), - // Metrics bind on a separate listener from the app so /metrics is never - // reverse-proxied to the internet through Caddy. Default ":9090" binds - // on all interfaces; nftables on the production hosts blocks the port - // on the public NIC and leaves it open on tailscale0, so the effective - // reach is "other tailnet peers only". Set to "" to disable the - // metrics listener entirely (useful in tests + local dev). - metricsAddr: envOr("METRICS_ADDR", ":9090"), - tokenHMACSecret: secret, - atmosphereKey: atmoKey, - etcdEndpoints: parseEndpoints(envOr("ETCD_ENDPOINTS", "127.0.0.1:2379")), - maxMonitors: maxMon, - } -} - -// parseAtmosphereKey decodes ATMOSPHERE_ENC_KEY and enforces exactly 32 bytes -// (AES-256). Extracted for unit testing the validation without going through -// log.Fatalf. -func parseAtmosphereKey(s string) ([]byte, error) { - b, err := decodeSecret(s) - if err != nil { - return nil, err - } - if len(b) != 32 { - return nil, fmt.Errorf("must decode to exactly 32 bytes; got %d", len(b)) - } - return b, nil -} - -// parseMaxMonitors validates the MAX_MONITORS env. Anything ≤0 is rejected -// because reservation logic compares against this value as the global cap; -// a zero or negative cap means "no monitors ever bind", which is almost -// certainly a misconfiguration the operator would want surfaced as a fatal -// startup error rather than silent rejection of every bind request. -func parseMaxMonitors(s string) (int64, error) { - n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64) - if err != nil { - return 0, fmt.Errorf("not an integer: %w", err) - } - if n <= 0 { - return 0, fmt.Errorf("must be > 0; got %d", n) - } - return n, nil -} - -// parseSecret decodes + validates a secret. Extracted from loadConfig so the -// length/format checks are unit-testable without going through log.Fatalf. -func parseSecret(s string) ([]byte, error) { - b, err := decodeSecret(s) - if err != nil { - return nil, err - } - if len(b) < 32 { - return nil, fmt.Errorf("must decode to at least 32 bytes; got %d", len(b)) - } - return b, nil -} - -func tokensHandler(secret []byte) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - token, expiresAt := issueToken(secret, time.Now().UTC()) - body, err := json.Marshal(struct { - Token string `json:"token"` - ExpiresAt time.Time `json:"expires_at"` - }{token, expiresAt}) - if err != nil { - log.Fatalf("marshal token response: %v", err) - } - w.Header().Set("Content-Type", "application/json") - // Write errors here mean the client disconnected — nothing to do. - _, _ = w.Write(body) - } -} - -// limitBody wraps a handler so the request body is capped at maxBytes. -// Client gets a 413 if it tries to send more. Applied at the mux level so -// we never accidentally read an unbounded body anywhere. -func limitBody(maxBytes int64, h http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, maxBytes) - h.ServeHTTP(w, r) - }) -} - -func envOr(key, fallback string) string { - if v := os.Getenv(key); v != "" { - return v - } - return fallback -} - -func mustEnv(key string) string { - v := os.Getenv(key) - if v == "" { - log.Fatalf("required env var %s is unset", key) - } - return v -} - -// parseEndpoints splits a comma list and trims surrounding whitespace per -// entry — `127.0.0.1:2379, 127.0.0.1:2380` would otherwise leak a leading -// space into the second endpoint and silently fail to dial. -func parseEndpoints(s string) []string { - parts := strings.Split(s, ",") - out := make([]string, 0, len(parts)) - for _, p := range parts { - if t := strings.TrimSpace(p); t != "" { - out = append(out, t) - } - } - return out -} - -// decodeSecret accepts hex or base64 (std or raw-url) and returns raw bytes. -// Format is sniffed by trying hex first, then base64; order matters because a -// hex string is also valid base64 but decodes to different bytes. -func decodeSecret(s string) ([]byte, error) { - s = strings.TrimSpace(s) - if b, err := hex.DecodeString(s); err == nil { - return b, nil - } - if b, err := base64.StdEncoding.DecodeString(s); err == nil { - return b, nil - } - if b, err := base64.RawStdEncoding.DecodeString(s); err == nil { - return b, nil - } - if b, err := base64.RawURLEncoding.DecodeString(s); err == nil { - return b, nil - } - return nil, fmt.Errorf("not valid hex or base64") -} - -func main() { - cfg := loadConfig() - - etcd, err := newEtcdClient(cfg.etcdEndpoints) - if err != nil { - log.Fatalf("etcd client: %v", err) - } - defer etcd.Close() - - // Fail fast if we can't reach the cluster — supervision restarts us. This - // also seeds etcdReachable so /status returns 200 from the first request, - // instead of racing the watchdog's first tick. - probeCtx, probeCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer probeCancel() - if _, err := etcd.Status(probeCtx, cfg.etcdEndpoints[0]); err != nil { - log.Fatalf("etcd probe: %v", err) - } - etcdReachable.Store(true) - - httpClient := newSafeHTTPClient() - enc, err := newAtmosphereEncryptor(cfg.atmosphereKey) - if err != nil { - log.Fatalf("atmosphere encryptor: %v", err) - } - verify := func(ctx context.Context, url, token string) (bool, observation) { - return verifyURL(ctx, httpClient, url, token) - } - check := func(ctx context.Context, url, expectedTokHash string) (bool, observation) { - return checkURL(ctx, httpClient, cfg.tokenHMACSecret, url, expectedTokHash) - } - bindAtmo := func(ctx context.Context, handle, appPassword string) (*atmosphereBinding, error) { - return prepareAtmosphere(ctx, httpClient, enc, handle, appPassword) - } - postAtmo := func(ctx context.Context, tokHash string, obs observation) { - doAtmospherePost(ctx, etcd, enc, httpClient, tokHash, obs) - } - - // appCtx is the signal-cancelled context that outlives individual HTTP - // requests. Declared here (before the mux) because the bind handler's - // initial-post goroutine needs a context that survives request return. - ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer cancel() - - mux := http.NewServeMux() - // /status is the supervisor-facing health probe — no auth, toggles with - // the watchdog's view of local etcd. Public API is proxied by Caddy on - // each node. /metrics lives on its own listener (metricsSrv below) and is - // NOT on this mux — see config.metricsAddr. - // `{$}` restricts the match to exactly "/", so the homepage doesn't - // shadow unknown paths (those still 404 cleanly through Caddy). - mux.Handle("GET /{$}", http.HandlerFunc(serveHomepage)) - mux.Handle("GET /status", etcdHealthHandler()) - mux.Handle("POST /api/v1/tokens", tokensHandler(cfg.tokenHMACSecret)) - mux.Handle("POST /api/v1/monitors", bindMonitorHandler(ctx, cfg.tokenHMACSecret, etcd, verify, cfg.maxMonitors, bindAtmo, postAtmo)) - mux.Handle("GET /api/v1/status", statusHandler(cfg.tokenHMACSecret, etcd)) - // Public OpenAPI spec — JSON for normal clients, HTML for link-preview - // bots (UA sniff in serveOpenAPI). openapi_test.go asserts the embedded - // spec lists exactly these routes. - mux.Handle("GET /api/v1/openapi", http.HandlerFunc(serveOpenAPI)) - mux.Handle("GET /api/v1/openapi.json", http.HandlerFunc(serveOpenAPI)) - mux.Handle("GET /api/v1/openapi.html", http.HandlerFunc(serveOpenAPIHTML)) - mux.Handle("GET /api/v1/openapi.jpg", http.HandlerFunc(serveOpenAPIImage)) - // SEO/discoverability surfaces — not part of the API contract, so - // deliberately excluded from openapi.json (TestOpenAPI_RoutesMatchSpec - // filters them out). See seo.go. - mux.Handle("GET /robots.txt", http.HandlerFunc(serveRobotsTxt)) - mux.Handle("GET /sitemap.xml", http.HandlerFunc(serveSitemapXML)) - mux.Handle("GET /llms.txt", http.HandlerFunc(serveLLMsTxt)) - // Admin-only surface: loopback-accessible only because the Caddyfile - // doesn't forward /admin/*. See admin.go. - mux.Handle("PATCH /admin/v1/monitors", adminUpdateMonitorHandler(cfg.tokenHMACSecret, etcd)) - - srv := &http.Server{ - Addr: cfg.listenAddr, - Handler: limitBody(maxRequestBodyBytes, mux), - ReadHeaderTimeout: serverTimeout, - ReadTimeout: serverTimeout, - WriteTimeout: serverWriteTimeout, - IdleTimeout: serverTimeout, - MaxHeaderBytes: maxRequestHeaderBytes, - } - - // Metrics listener: a tiny separate server that only exposes /metrics. - // Kept off the main mux deliberately so Caddy never proxies scrape - // traffic to the public internet. On production hosts nftables opens - // this port only on tailscale0, so effective reach is tailnet peers. - var metricsSrv *http.Server - if cfg.metricsAddr != "" { - metricsMux := http.NewServeMux() - metricsMux.Handle("GET /metrics", promhttp.Handler()) - metricsSrv = &http.Server{ - Addr: cfg.metricsAddr, - Handler: metricsMux, - ReadHeaderTimeout: serverTimeout, - ReadTimeout: serverTimeout, - WriteTimeout: serverTimeout, - IdleTimeout: serverTimeout, - MaxHeaderBytes: maxRequestHeaderBytes, - } - } - - go func() { - log.Printf("listening on %s", cfg.listenAddr) - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("http server: %v", err) - } - }() - if metricsSrv != nil { - go func() { - log.Printf("metrics listening on %s", cfg.metricsAddr) - if err := metricsSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("metrics server: %v", err) - } - }() - } - go runChecker(ctx, etcd, check, postAtmo) - go runWatchdog(ctx, etcd, cfg.etcdEndpoints[0]) - - <-ctx.Done() - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer shutdownCancel() - if err := srv.Shutdown(shutdownCtx); err != nil { - log.Fatalf("shutdown: %v", err) - } - if metricsSrv != nil { - if err := metricsSrv.Shutdown(shutdownCtx); err != nil { - log.Fatalf("metrics shutdown: %v", err) - } - } -} diff --git a/main_test.go b/main_test.go deleted file mode 100644 index c2b6f58..0000000 --- a/main_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package main - -import ( - "encoding/hex" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" -) - -func TestParseSecret_AcceptsHexAndBase64(t *testing.T) { - raw := make([]byte, 32) - for i := range raw { - raw[i] = byte(i) - } - cases := map[string]string{ - "hex": hex.EncodeToString(raw), - "base64 std": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", - "base64 raw std": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8", - } - for name, s := range cases { - got, err := parseSecret(s) - if err != nil { - t.Fatalf("%s: parseSecret returned error: %v", name, err) - } - if string(got) != string(raw) { - t.Fatalf("%s: decoded bytes mismatch", name) - } - } -} - -func TestParseSecret_RejectsShort(t *testing.T) { - // 31 bytes hex-encoded — one byte short of the 32-byte minimum - s := hex.EncodeToString(make([]byte, 31)) - if _, err := parseSecret(s); err == nil { - t.Fatal("parseSecret accepted a 31-byte secret") - } -} - -func TestParseSecret_RejectsGarbage(t *testing.T) { - if _, err := parseSecret("not hex not base64 !!!"); err == nil { - t.Fatal("parseSecret accepted garbage input") - } -} - -func TestParseEndpoints(t *testing.T) { - cases := map[string][]string{ - "127.0.0.1:2379": {"127.0.0.1:2379"}, - "127.0.0.1:2379,127.0.0.1:2380": {"127.0.0.1:2379", "127.0.0.1:2380"}, - "127.0.0.1:2379, 127.0.0.1:2380": {"127.0.0.1:2379", "127.0.0.1:2380"}, - " 127.0.0.1:2379 , 127.0.0.1:2380 ": {"127.0.0.1:2379", "127.0.0.1:2380"}, - "127.0.0.1:2379,,127.0.0.1:2380": {"127.0.0.1:2379", "127.0.0.1:2380"}, - } - for in, want := range cases { - got := parseEndpoints(in) - if len(got) != len(want) { - t.Fatalf("parseEndpoints(%q) = %v; want %v", in, got, want) - } - for i := range got { - if got[i] != want[i] { - t.Fatalf("parseEndpoints(%q)[%d] = %q; want %q", in, i, got[i], want[i]) - } - } - } -} - -func TestTokensHandler_ReturnsValidToken(t *testing.T) { - secret := mustSecret(t) - req := httptest.NewRequest(http.MethodPost, "/api/v1/tokens", nil) - rec := httptest.NewRecorder() - - tokensHandler(secret).ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d; want 200", rec.Code) - } - if ct := rec.Header().Get("Content-Type"); ct != "application/json" { - t.Fatalf("Content-Type = %q; want application/json", ct) - } - - var body struct { - Token string `json:"token"` - ExpiresAt time.Time `json:"expires_at"` - } - if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { - t.Fatalf("decode response body: %v", err) - } - if !strings.HasPrefix(body.Token, tokenPrefix) { - t.Fatalf("token %q missing prefix %q", body.Token, tokenPrefix) - } - if _, ok := verifyToken(secret, body.Token, time.Now()); !ok { - t.Fatal("returned token failed HMAC verification against issuing secret") - } - if time.Until(body.ExpiresAt) < 59*time.Minute { - t.Fatalf("expires_at = %v; want ~1h from now", body.ExpiresAt) - } -} diff --git a/monitors.go b/monitors.go deleted file mode 100644 index 0e01442..0000000 --- a/monitors.go +++ /dev/null @@ -1,423 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "log" - "math/rand/v2" - "net/http" - "strconv" - "strings" - "time" - - clientv3 "go.etcd.io/etcd/client/v3" -) - -// Per-monitor and global caps. perDomainCap is also documented in openapi.json -// (Caps section) and CLAUDE.md (Data model). Keep in sync with both. -const ( - perDomainCap = 10 - defaultMaxMonitors = 10000 - bindLeaseTTLSeconds = 30 - phase1MaxRetries = 3 - defaultCheckIntervalSec = 3600 - firstCheckJitter = 5 * time.Second -) - -// monitorBlob is the JSON value at monitor/. Schema is internal -// (etcd-only) — the public response shapes live in openapi.json's StatusResponse. -// -// IntervalSeconds isn't exposed by the API (yet); operators set it directly -// in etcd for special monitors (e.g. the canary). Zero/missing reads as the -// default so legacy blobs and normal binds keep their original cadence. -type monitorBlob struct { - URL string `json:"url"` - ETLD1 string `json:"etld1"` - LastTokenSeenAt time.Time `json:"last_token_seen_at"` - CreatedAt time.Time `json:"created_at"` - Current observation `json:"current"` - History []observation `json:"history"` - IntervalSeconds int64 `json:"interval_seconds,omitempty"` - Atmosphere *atmosphereBinding `json:"atmosphere,omitempty"` -} - -// effectiveInterval returns the monitor's configured check interval, falling -// back to the default when the field is unset (legacy blob, or normal bind). -func (b *monitorBlob) effectiveInterval() time.Duration { - if b.IntervalSeconds <= 0 { - return defaultCheckIntervalSec * time.Second - } - return time.Duration(b.IntervalSeconds) * time.Second -} - -// firstCheckAt picks the FIRST due-time for a monitor: now + uniform random -// in [0, firstCheckJitter). Once placed on the schedule, all subsequent -// reschedules land on the same grid by adding `interval` exactly. The 5s -// jitter is enough to spread simultaneous binds without smearing the phase -// across the whole interval — operators who want a specific phase (e.g. -// the meta-monitors landing mid-minute, well clear of the canary's -// :00 cycle boundary) can simply bind at the desired wall-clock time. -func firstCheckAt(now time.Time, _ time.Duration) time.Time { - return now.Add(time.Duration(rand.Int64N(int64(firstCheckJitter)))) -} - -// extractBearer returns the token portion of an `Authorization: Bearer ` -// header, or "" if the header is absent or malformed. Format check only — -// HMAC verification is the caller's responsibility. -func extractBearer(r *http.Request) string { - h := r.Header.Get("Authorization") - rest, ok := strings.CutPrefix(h, "Bearer ") - if !ok { - return "" - } - return strings.TrimSpace(rest) -} - -// verifyFn matches verifyURL's shape minus the http.Client (which is bound -// at handler construction). Injectable so handler tests don't depend on -// prepareURL accepting bare-IP test-server URLs. -type verifyFn func(ctx context.Context, url, token string) (bool, observation) - -// appCtx is the shutdown-cancelled context the bind handler uses to -// spawn its initial-post goroutine after a successful atmosphere bind — -// r.Context() would cancel the moment we respond, cutting off the post -// before it can finish. -func bindMonitorHandler(appCtx context.Context, secret []byte, etcd *clientv3.Client, verify verifyFn, maxMonitors int64, bindAtmo atmosphereBindFn, postAtmo atmospherePostFn) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - token := extractBearer(r) - if token == "" { - metricBindTotal.WithLabelValues("unauthorized").Inc() - http.Error(w, "missing bearer token", http.StatusUnauthorized) - return - } - if _, ok := verifyToken(secret, token, time.Now()); !ok { - metricBindTotal.WithLabelValues("unauthorized").Inc() - http.Error(w, "invalid bearer token", http.StatusUnauthorized) - return - } - - var body struct { - URL string `json:"url"` - Atmosphere *struct { - Handle string `json:"handle"` - AppPassword string `json:"app_password"` - } `json:"atmosphere,omitempty"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - // http.MaxBytesReader surfaces oversize as *http.MaxBytesError. - var maxBytesErr *http.MaxBytesError - if errors.As(err, &maxBytesErr) { - metricBindTotal.WithLabelValues("oversize").Inc() - http.Error(w, "request body too large", http.StatusRequestEntityTooLarge) - return - } - metricBindTotal.WithLabelValues("bad_request").Inc() - http.Error(w, "invalid JSON body", http.StatusBadRequest) - return - } - - normalized, etld1, err := prepareURL(body.URL) - if err != nil { - metricBindTotal.WithLabelValues("bad_request").Inc() - http.Error(w, fmt.Sprintf("invalid url: %v", err), http.StatusBadRequest) - return - } - - tokHash := tokenHash(token) - - // Idempotent re-bind: if monitor already exists for this token, skip - // phase 1 (slot is held), verify, then overwrite blob + reschedule. - // On verify failure we leave the existing monitor untouched. - existing, err := etcd.Get(ctx, monitorKey(tokHash)) - if err != nil { - log.Fatalf("etcd get monitor: %v", err) - } - if existing.Count > 0 { - // Atmosphere is sticky after first bind: the encrypted credential - // is preserved across re-binds, and any attempt to supply a new - // one is rejected so the caller doesn't think they updated it. - // To swap accounts: stop echoing the token, wait 24h for - // auto-stop, then re-bind from a fresh token. - if body.Atmosphere != nil { - metricBindTotal.WithLabelValues("bad_request").Inc() - http.Error(w, "atmosphere cannot be changed after first bind; stop echoing the token to delete the monitor and re-bind from a fresh token", http.StatusBadRequest) - return - } - var prior monitorBlob - if err := json.Unmarshal(existing.Kvs[0].Value, &prior); err != nil { - log.Fatalf("unmarshal existing blob: %v", err) - } - doVerifyAndCommit(appCtx, ctx, w, etcd, verify, postAtmo, normalized, etld1, token, tokHash, &prior) - return - } - - // Fresh bind path. If atmosphere was supplied, resolve + verify + - // empty-check + encrypt BEFORE touching etcd — any failure here - // aborts the whole bind, no rollback needed. The app password stays - // in a local []byte and is zeroed inside prepareAtmosphere once - // encryption is done. bindAtmo is nil only in tests that don't - // exercise the atmosphere path; we reject such requests 400 instead - // of panicking on the nil call. - var atmo *atmosphereBinding - if body.Atmosphere != nil { - if bindAtmo == nil { - metricBindTotal.WithLabelValues("bad_request").Inc() - http.Error(w, "atmosphere not configured", http.StatusBadRequest) - return - } - a, err := bindAtmo(ctx, body.Atmosphere.Handle, body.Atmosphere.AppPassword) - if err != nil { - metricBindTotal.WithLabelValues("bad_request").Inc() - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - atmo = a - } - - // Phase 1: reserve a slot via CAS on the two counter keys. - lease, ok := reserveSlot(ctx, etcd, etld1, tokHash, maxMonitors) - if !ok { - metricBindTotal.WithLabelValues("cap").Inc() - http.Error(w, "monitor cap reached", http.StatusTooManyRequests) - return - } - - fresh := monitorBlob{Atmosphere: atmo} - if !doVerifyAndCommit(appCtx, ctx, w, etcd, verify, postAtmo, normalized, etld1, token, tokHash, &fresh) { - // Rollback releases the slot. Lease auto-expires anyway, but - // rolling back synchronously means a retry can succeed without - // waiting bindLeaseTTLSeconds for the scavenger. - rollbackSlot(ctx, etcd, etld1, tokHash, lease) - } - } -} - -// reserveSlot performs phase 1: read both counter keys, CAS-condition on -// their values being unchanged, and on success put the bind_pending lease, -// the etld/ membership marker, and the two incremented counters in one txn. -// -// Returns the granted lease (so rollback can revoke it) and a success bool. -// Bounded retry on CAS conflict — concurrent binds on the same eTLD+1 race -// the same counter; phase1MaxRetries gives them a few rounds to serialize. -func reserveSlot(ctx context.Context, etcd *clientv3.Client, etld1, tokHash string, maxMonitors int64) (clientv3.LeaseID, bool) { - for range phase1MaxRetries { - etldVal, etldNum, err := readCounter(ctx, etcd, etldCountKey(etld1)) - if err != nil { - log.Fatalf("etcd get etld_count: %v", err) - } - statsVal, statsNum, err := readCounter(ctx, etcd, keyStatsCount) - if err != nil { - log.Fatalf("etcd get stats count: %v", err) - } - if etldNum >= perDomainCap || statsNum >= maxMonitors { - return 0, false - } - - lease, err := etcd.Grant(ctx, bindLeaseTTLSeconds) - if err != nil { - log.Fatalf("etcd grant lease: %v", err) - } - - txn := etcd.Txn(ctx). - If( - counterCmp(etldCountKey(etld1), etldVal), - counterCmp(keyStatsCount, statsVal), - ). - Then( - clientv3.OpPut(bindPendingKey(tokHash), "", clientv3.WithLease(lease.ID)), - clientv3.OpPut(etldKey(etld1, tokHash), ""), - clientv3.OpPut(etldCountKey(etld1), strconv.FormatInt(etldNum+1, 10)), - clientv3.OpPut(keyStatsCount, strconv.FormatInt(statsNum+1, 10)), - ) - resp, err := txn.Commit() - if err != nil { - log.Fatalf("etcd reserve txn: %v", err) - } - if resp.Succeeded { - return lease.ID, true - } - // CAS lost — another binder beat us. Revoke the unused lease - // promptly so we don't pile them up if the cluster is under contention. - _, _ = etcd.Revoke(ctx, lease.ID) - } - return 0, false -} - -// readCounter returns the raw etcd value (for Value-equality CAS) and the -// parsed int (for cap comparison). Absent key reads as ("", 0) so callers -// can use the same CAS shape whether the counter exists yet or not. -func readCounter(ctx context.Context, etcd *clientv3.Client, key string) (string, int64, error) { - resp, err := etcd.Get(ctx, key) - if err != nil { - return "", 0, err - } - if resp.Count == 0 { - return "", 0, nil - } - val := string(resp.Kvs[0].Value) - n, err := strconv.ParseInt(val, 10, 64) - if err != nil { - return "", 0, fmt.Errorf("counter %q has non-int value %q: %w", key, val, err) - } - return val, n, nil -} - -// counterCmp builds the CAS precondition matching readCounter's ("", 0) -// convention: absent key ⇒ CreateRevision == 0; present key ⇒ Value unchanged. -func counterCmp(key, currentVal string) clientv3.Cmp { - if currentVal == "" { - return clientv3.Compare(clientv3.CreateRevision(key), "=", 0) - } - return clientv3.Compare(clientv3.Value(key), "=", currentVal) -} - -// doVerifyAndCommit runs phases 2 + 3. On verify success: writes monitor blob -// + initial due key in one txn. On verify failure: writes a 4xx response with -// the normalized error category and returns false so the caller can rollback -// the phase-1 reservation (new-bind path only — idempotent path has no -// reservation to roll back). -// -// `prior` is the seed blob: for fresh bind, only its Atmosphere field matters -// (rest zero); for idempotent re-bind, it's the existing blob, and we keep -// its Atmosphere (sticky-credential rule), CreatedAt (history-of-monitor) -// and History (change ring buffer). CreatedAt.IsZero() distinguishes the two -// modes — also drives whether the commit txn requires the bind_pending CAS. -func doVerifyAndCommit(appCtx, ctx context.Context, w http.ResponseWriter, etcd *clientv3.Client, verify verifyFn, postAtmo atmospherePostFn, normalized, etld1, token, tokHash string, prior *monitorBlob) bool { - matched, obs := verify(ctx, normalized, token) - // token_mismatch gets through — binding succeeds with the failing - // observation recorded. The 60-min check loop will revalidate, and - // auto-stop fires 24h after bind if the echo never comes online. - // Gives users a "bind first, wire up echo second" workflow without - // having to race their configuration against the bind request. - // Other verify failures (dns/tls/blocked/timeout/refused/other) still - // reject: those indicate the URL itself is wrong or unreachable, - // which the user should fix before a monitor exists. - if !matched && obs.Error != errCatTokenMismatch { - metricBindTotal.WithLabelValues("verify_failed").Inc() - writeVerifyError(w, obs.Error) - return false - } - - now := time.Now().UTC() - idempotent := !prior.CreatedAt.IsZero() - createdAt := now - history := []observation{} - if idempotent { - createdAt = prior.CreatedAt - history = prior.History - } - blob := monitorBlob{ - URL: normalized, - ETLD1: etld1, - LastTokenSeenAt: now, - CreatedAt: createdAt, - Current: obs, - History: history, - Atmosphere: prior.Atmosphere, - } - next := firstCheckAt(now, blob.effectiveInterval()) - blobBytes, err := json.Marshal(blob) - if err != nil { - log.Fatalf("marshal monitor blob: %v", err) - } - - commit := etcd.Txn(ctx) - if !idempotent { - // Phase 1 grants a lease to bind_pending; if the lease expired during - // verify (process slow / GC pause) the reservation is gone and the - // verify result is no longer trusted. CreateRevision != 0 catches that. - commit = commit.If(clientv3.Compare(clientv3.CreateRevision(bindPendingKey(tokHash)), "!=", 0)) - } - commit = commit.Then( - clientv3.OpDelete(bindPendingKey(tokHash)), - clientv3.OpPut(monitorKey(tokHash), string(blobBytes)), - clientv3.OpPut(dueKey(next, tokHash), ""), - ) - resp, err := commit.Commit() - if err != nil { - log.Fatalf("etcd commit txn: %v", err) - } - if !resp.Succeeded { - // Lease expired between phase 1 and now. Treat as failure; caller rolls back. - metricBindTotal.WithLabelValues("lease_expired").Inc() - http.Error(w, "bind lease expired", http.StatusServiceUnavailable) - return false - } - - metricBindTotal.WithLabelValues("ok").Inc() - - // Initial post: "here's what I'm starting from" — fires on every - // successful bind (fresh or idempotent) that carries an atmosphere - // binding. Detached from r.Context() via appCtx because the handler - // returns immediately after WriteHeader — the request ctx would - // cancel mid-post otherwise. - if blob.Atmosphere != nil && postAtmo != nil { - go postAtmo(appCtx, tokHash, obs) - } - - respBody, err := json.Marshal(struct { - URL string `json:"url"` - NextCheckAt time.Time `json:"next_check_at"` - }{normalized, next}) - if err != nil { - log.Fatalf("marshal bind response: %v", err) - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - _, _ = w.Write(respBody) - return true -} - -// writeVerifyError maps a non-token-mismatch verifyURL error category to a -// 400 + plain-text body. Token mismatch is handled upstream (accept-pending -// path) and doesn't reach here. -func writeVerifyError(w http.ResponseWriter, category string) { - http.Error(w, category, http.StatusBadRequest) -} - -// rollbackSlot undoes a phase-1 reservation: deletes etld/ membership and -// decrements both counters under CAS, and revokes the lease (which will also -// remove bind_pending if it still exists). Bounded retry on CAS conflict. -// -// All inputs to the decrement come from re-reading current state, so a -// concurrent bind on the same eTLD+1 incrementing in parallel is safe — we -// just retry against the new value. -func rollbackSlot(ctx context.Context, etcd *clientv3.Client, etld1, tokHash string, lease clientv3.LeaseID) { - for range phase1MaxRetries { - etldVal, etldNum, err := readCounter(ctx, etcd, etldCountKey(etld1)) - if err != nil { - log.Fatalf("etcd rollback get etld_count: %v", err) - } - statsVal, statsNum, err := readCounter(ctx, etcd, keyStatsCount) - if err != nil { - log.Fatalf("etcd rollback get stats: %v", err) - } - // Don't dec below zero — defensive against scavenger having already - // rolled back. v0 has no scavenger yet, but the guard is cheap. - newEtld := max(etldNum-1, 0) - newStats := max(statsNum-1, 0) - resp, err := etcd.Txn(ctx). - If( - counterCmp(etldCountKey(etld1), etldVal), - counterCmp(keyStatsCount, statsVal), - ). - Then( - clientv3.OpDelete(etldKey(etld1, tokHash)), - clientv3.OpPut(etldCountKey(etld1), strconv.FormatInt(newEtld, 10)), - clientv3.OpPut(keyStatsCount, strconv.FormatInt(newStats, 10)), - ).Commit() - if err != nil { - log.Fatalf("etcd rollback txn: %v", err) - } - if resp.Succeeded { - break - } - } - if lease != 0 { - _, _ = etcd.Revoke(ctx, lease) - } -} diff --git a/monitors_test.go b/monitors_test.go deleted file mode 100644 index c54d77f..0000000 --- a/monitors_test.go +++ /dev/null @@ -1,616 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/hex" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "strconv" - "strings" - "sync/atomic" - "testing" - "time" - - clientv3 "go.etcd.io/etcd/client/v3" -) - -// fakeVerifier returns a verifier that pretends every URL echoes the token -// and bumps a call counter so tests can assert "no HEAD went out" on the -// rejection paths. -func fakeVerifier(matched bool, calls *atomic.Int64) verifyFn { - return func(_ context.Context, _, _ string) (bool, observation) { - calls.Add(1) - obs := observation{HTTPStatus: 200, ObservedAt: time.Now().UTC()} - if !matched { - obs.Error = errCatTokenMismatch - } - return matched, obs - } -} - -// fakeVerifierWithError is a verifier that always reports failure with a -// specific normalized error category. Used to exercise the "hard fail" -// rollback path (dns/tls/blocked/timeout/refused/other) now that -// token_mismatch takes the accept-pending branch instead. -func fakeVerifierWithError(errCat string, calls *atomic.Int64) verifyFn { - return func(_ context.Context, _, _ string) (bool, observation) { - calls.Add(1) - return false, observation{Error: errCat, ObservedAt: time.Now().UTC()} - } -} - -// issueTestToken mirrors what POST /api/v1/tokens would produce — a real, -// HMAC-valid token the bind handler will accept. -func issueTestToken(secret []byte) string { - tok, _ := issueToken(secret, time.Now()) - return tok -} - -// bindReq builds a JSON request for POST /api/v1/monitors with the supplied -// bearer token + URL. Body wrapped through MaxBytesReader so the handler's -// 4 KiB cap exercises the real path. -func bindReq(token, url string) *http.Request { - body, _ := json.Marshal(map[string]string{"url": url}) - r := httptest.NewRequest(http.MethodPost, "/api/v1/monitors", bytes.NewReader(body)) - r.Body = http.MaxBytesReader(nil, r.Body, maxRequestBodyBytes) - if token != "" { - r.Header.Set("Authorization", "Bearer "+token) - } - r.Header.Set("Content-Type", "application/json") - return r -} - -func TestBind_Success(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - var calls atomic.Int64 - h := bindMonitorHandler(context.Background(), secret, cli, fakeVerifier(true, &calls), 100, nil, nil) - - tok := issueTestToken(secret) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, bindReq(tok, "https://www.example.com/")) - - if rec.Code != http.StatusCreated { - t.Fatalf("status = %d (%s); want 201", rec.Code, rec.Body.String()) - } - if calls.Load() != 1 { - t.Errorf("verifier calls = %d; want 1", calls.Load()) - } - - var resp struct { - URL string `json:"url"` - NextCheckAt time.Time `json:"next_check_at"` - } - if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { - t.Fatalf("decode response: %v", err) - } - if resp.URL != "https://www.example.com/" { - t.Errorf("url = %q; want https://www.example.com/", resp.URL) - } - if resp.NextCheckAt.Before(time.Now()) { - t.Errorf("next_check_at = %v; want future", resp.NextCheckAt) - } - - // etcd state: monitor blob exists, both counters = 1, etld/ entry present, due/ key present. - ctx := context.Background() - tokHash := tokenHash(tok) - - mon, _ := cli.Get(ctx, monitorKey(tokHash)) - if mon.Count != 1 { - t.Errorf("monitor key count = %d; want 1", mon.Count) - } - statsResp, _ := cli.Get(ctx, keyStatsCount) - if string(statsResp.Kvs[0].Value) != "1" { - t.Errorf("stats/monitor_count = %q; want 1", statsResp.Kvs[0].Value) - } - etldCntResp, _ := cli.Get(ctx, etldCountKey("example.com")) - if string(etldCntResp.Kvs[0].Value) != "1" { - t.Errorf("etld_count/example.com = %q; want 1", etldCntResp.Kvs[0].Value) - } - etldResp, _ := cli.Get(ctx, etldKey("example.com", tokHash)) - if etldResp.Count != 1 { - t.Errorf("etld/example.com/ count = %d; want 1", etldResp.Count) - } - dueResp, _ := cli.Get(ctx, keyPrefixDue, clientv3.WithPrefix()) - if dueResp.Count != 1 { - t.Errorf("due/ count = %d; want 1", dueResp.Count) - } - pendingResp, _ := cli.Get(ctx, bindPendingKey(tokHash)) - if pendingResp.Count != 0 { - t.Errorf("bind_pending/ count = %d; want 0 (cleared on commit)", pendingResp.Count) - } -} - -func TestBind_NoBearer_401(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - var calls atomic.Int64 - h := bindMonitorHandler(context.Background(), secret, cli, fakeVerifier(true, &calls), 100, nil, nil) - - rec := httptest.NewRecorder() - h.ServeHTTP(rec, bindReq("", "https://www.example.com/")) - - if rec.Code != http.StatusUnauthorized { - t.Fatalf("status = %d; want 401", rec.Code) - } - if calls.Load() != 0 { - t.Errorf("verifier called %d times on unauth path; want 0", calls.Load()) - } -} - -func TestBind_BadToken_401(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - var calls atomic.Int64 - h := bindMonitorHandler(context.Background(), secret, cli, fakeVerifier(true, &calls), 100, nil, nil) - - rec := httptest.NewRecorder() - h.ServeHTTP(rec, bindReq("pa_garbage", "https://www.example.com/")) - - if rec.Code != http.StatusUnauthorized { - t.Fatalf("status = %d; want 401", rec.Code) - } - if calls.Load() != 0 { - t.Errorf("verifier called %d times on bad-token path; want 0", calls.Load()) - } -} - -func TestBind_BadURL_400(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - var calls atomic.Int64 - h := bindMonitorHandler(context.Background(), secret, cli, fakeVerifier(true, &calls), 100, nil, nil) - - rec := httptest.NewRecorder() - h.ServeHTTP(rec, bindReq(issueTestToken(secret), "http://example.com/")) - - if rec.Code != http.StatusBadRequest { - t.Fatalf("status = %d; want 400", rec.Code) - } - if calls.Load() != 0 { - t.Errorf("verifier called %d times on bad-URL path; want 0 (must reject before HEAD)", calls.Load()) - } -} - -func TestBind_OversizeBody_413(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - var calls atomic.Int64 - h := bindMonitorHandler(context.Background(), secret, cli, fakeVerifier(true, &calls), 100, nil, nil) - - huge := strings.Repeat("a", maxRequestBodyBytes+1024) - body, _ := json.Marshal(map[string]string{"url": "https://" + huge + ".example.com/"}) - r := httptest.NewRequest(http.MethodPost, "/api/v1/monitors", bytes.NewReader(body)) - r.Body = http.MaxBytesReader(nil, r.Body, maxRequestBodyBytes) - r.Header.Set("Authorization", "Bearer "+issueTestToken(secret)) - - rec := httptest.NewRecorder() - h.ServeHTTP(rec, r) - - if rec.Code != http.StatusRequestEntityTooLarge { - t.Fatalf("status = %d; want 413", rec.Code) - } - if calls.Load() != 0 { - t.Errorf("verifier called %d times on oversize body; want 0", calls.Load()) - } -} - -func TestBind_PerDomainCap_429_NoHeadFired(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - var calls atomic.Int64 - h := bindMonitorHandler(context.Background(), secret, cli, fakeVerifier(true, &calls), 1000, nil, nil) - - // Fill the eTLD+1 to the cap with N distinct tokens. - for i := range perDomainCap { - rec := httptest.NewRecorder() - h.ServeHTTP(rec, bindReq(issueTestToken(secret), fmt.Sprintf("https://www.example.com/%d", i))) - if rec.Code != http.StatusCreated { - t.Fatalf("fill bind %d: status = %d (%s); want 201", i, rec.Code, rec.Body.String()) - } - } - wantCalls := int64(perDomainCap) - if calls.Load() != wantCalls { - t.Fatalf("verifier calls after fill = %d; want %d", calls.Load(), wantCalls) - } - - // (perDomainCap+1)th: must reject with 429 *without* invoking the verifier. - rec := httptest.NewRecorder() - h.ServeHTTP(rec, bindReq(issueTestToken(secret), "https://www.example.com/extra")) - if rec.Code != http.StatusTooManyRequests { - t.Fatalf("over-cap bind: status = %d; want 429", rec.Code) - } - if calls.Load() != wantCalls { - t.Errorf("verifier calls after over-cap = %d; want %d (no HEAD on rejection)", calls.Load(), wantCalls) - } - - // etld_count must show exactly perDomainCap (the rejected bind didn't increment). - cnt, _ := cli.Get(context.Background(), etldCountKey("example.com")) - if string(cnt.Kvs[0].Value) != strconv.Itoa(perDomainCap) { - t.Errorf("etld_count/example.com = %q; want %d", cnt.Kvs[0].Value, perDomainCap) - } -} - -func TestBind_GlobalCap_429(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - var calls atomic.Int64 - h := bindMonitorHandler(context.Background(), secret, cli, fakeVerifier(true, &calls), 1, nil, nil) - - rec := httptest.NewRecorder() - h.ServeHTTP(rec, bindReq(issueTestToken(secret), "https://www.example.com/")) - if rec.Code != http.StatusCreated { - t.Fatalf("first bind: status = %d; want 201", rec.Code) - } - - // Different eTLD+1, same global counter — must hit the global cap (1). - rec = httptest.NewRecorder() - h.ServeHTTP(rec, bindReq(issueTestToken(secret), "https://www.other.org/")) - if rec.Code != http.StatusTooManyRequests { - t.Fatalf("over-global-cap: status = %d; want 429", rec.Code) - } - if calls.Load() != 1 { - t.Errorf("verifier calls = %d; want 1 (second bind should not HEAD)", calls.Load()) - } -} - -func TestBind_VerifyFails_RollsBack(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - var calls atomic.Int64 - // DNS failure: URL is wrong / unreachable — bind must still reject so - // the user doesn't end up with a monitor pointed at nothing. - h := bindMonitorHandler(context.Background(), secret, cli, fakeVerifierWithError(errCatDNS, &calls), 100, nil, nil) - - tok := issueTestToken(secret) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, bindReq(tok, "https://www.example.com/")) - - if rec.Code != http.StatusBadRequest { - t.Fatalf("status = %d (%s); want 400 on dns failure", rec.Code, rec.Body.String()) - } - if calls.Load() != 1 { - t.Errorf("verifier calls = %d; want 1", calls.Load()) - } - - ctx := context.Background() - tokHash := tokenHash(tok) - - // All reservation state must be gone. - mon, _ := cli.Get(ctx, monitorKey(tokHash)) - if mon.Count != 0 { - t.Errorf("monitor key present after rollback: count=%d", mon.Count) - } - etldResp, _ := cli.Get(ctx, etldKey("example.com", tokHash)) - if etldResp.Count != 0 { - t.Errorf("etld/example.com/ present after rollback: count=%d", etldResp.Count) - } - dueResp, _ := cli.Get(ctx, keyPrefixDue, clientv3.WithPrefix()) - if dueResp.Count != 0 { - t.Errorf("due/ keys present after rollback: count=%d", dueResp.Count) - } - - // Counters back to zero — no drift. - statsResp, _ := cli.Get(ctx, keyStatsCount) - if string(statsResp.Kvs[0].Value) != "0" { - t.Errorf("stats/monitor_count = %q; want 0 after rollback", statsResp.Kvs[0].Value) - } - etldCntResp, _ := cli.Get(ctx, etldCountKey("example.com")) - if string(etldCntResp.Kvs[0].Value) != "0" { - t.Errorf("etld_count/example.com = %q; want 0 after rollback", etldCntResp.Kvs[0].Value) - } - - // A fresh successful bind on the same eTLD+1 must work — slot was released. - calls.Store(0) - h2 := bindMonitorHandler(context.Background(), secret, cli, fakeVerifier(true, &calls), 100, nil, nil) - rec = httptest.NewRecorder() - h2.ServeHTTP(rec, bindReq(issueTestToken(secret), "https://www.example.com/")) - if rec.Code != http.StatusCreated { - t.Fatalf("post-rollback bind: status = %d; want 201", rec.Code) - } -} - -func TestBind_TokenMismatch_AcceptsPending(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - var calls atomic.Int64 - h := bindMonitorHandler(context.Background(), secret, cli, fakeVerifier(false, &calls), 100, nil, nil) - - tok := issueTestToken(secret) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, bindReq(tok, "https://www.example.com/")) - - // Must be 201 now — verify returned matched=false with token_mismatch; - // bind still succeeds so user can wire up the echo header afterward. - if rec.Code != http.StatusCreated { - t.Fatalf("status = %d (%s); want 201 on token mismatch (accept pending)", rec.Code, rec.Body.String()) - } - - ctx := context.Background() - tokHash := tokenHash(tok) - - // Monitor exists with the failing observation recorded in Current, and - // counters bumped exactly as they would be for a successful bind. - mon, _ := cli.Get(ctx, monitorKey(tokHash)) - if mon.Count != 1 { - t.Fatalf("monitor blob not written: count=%d", mon.Count) - } - var blob monitorBlob - if err := json.Unmarshal(mon.Kvs[0].Value, &blob); err != nil { - t.Fatalf("unmarshal blob: %v", err) - } - if blob.Current.Error != errCatTokenMismatch { - t.Errorf("Current.Error = %q; want %q", blob.Current.Error, errCatTokenMismatch) - } - if blob.LastTokenSeenAt.IsZero() { - t.Error("LastTokenSeenAt = zero; want bind-time (starts 24h auto-stop grace)") - } - statsResp, _ := cli.Get(ctx, keyStatsCount) - if string(statsResp.Kvs[0].Value) != "1" { - t.Errorf("stats/monitor_count = %q; want 1", statsResp.Kvs[0].Value) - } -} - -func TestBind_IdempotentRebind_CountersUntouched(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - var calls atomic.Int64 - h := bindMonitorHandler(context.Background(), secret, cli, fakeVerifier(true, &calls), 100, nil, nil) - - // First bind. - tok := issueTestToken(secret) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, bindReq(tok, "https://www.example.com/")) - if rec.Code != http.StatusCreated { - t.Fatalf("first bind: status = %d (%s); want 201", rec.Code, rec.Body.String()) - } - - // Re-bind same token, different URL on same eTLD+1. - rec = httptest.NewRecorder() - h.ServeHTTP(rec, bindReq(tok, "https://www.example.com/v2")) - if rec.Code != http.StatusCreated { - t.Fatalf("rebind: status = %d (%s); want 201", rec.Code, rec.Body.String()) - } - - // Verifier was called twice, but counters stayed at 1. - if calls.Load() != 2 { - t.Errorf("verifier calls = %d; want 2", calls.Load()) - } - ctx := context.Background() - statsResp, _ := cli.Get(ctx, keyStatsCount) - if string(statsResp.Kvs[0].Value) != "1" { - t.Errorf("stats/monitor_count = %q; want 1 (idempotent re-bind must not inc)", statsResp.Kvs[0].Value) - } - etldCntResp, _ := cli.Get(ctx, etldCountKey("example.com")) - if string(etldCntResp.Kvs[0].Value) != "1" { - t.Errorf("etld_count/example.com = %q; want 1 (idempotent re-bind must not inc)", etldCntResp.Kvs[0].Value) - } - - // Monitor blob should reflect the new URL. - mon, _ := cli.Get(ctx, monitorKey(tokenHash(tok))) - if mon.Count != 1 { - t.Fatalf("monitor key count = %d; want 1", mon.Count) - } - var blob monitorBlob - if err := json.Unmarshal(mon.Kvs[0].Value, &blob); err != nil { - t.Fatalf("unmarshal monitor blob: %v", err) - } - if blob.URL != "https://www.example.com/v2" { - t.Errorf("blob.URL = %q; want https://www.example.com/v2", blob.URL) - } -} - -// bindReqWithAtmosphere extends bindReq with an atmosphere object in the -// JSON body. -func bindReqWithAtmosphere(token, url, handle, appPassword string) *http.Request { - body, _ := json.Marshal(map[string]any{ - "url": url, - "atmosphere": map[string]string{ - "handle": handle, - "app_password": appPassword, - }, - }) - r := httptest.NewRequest(http.MethodPost, "/api/v1/monitors", bytes.NewReader(body)) - r.Body = http.MaxBytesReader(nil, r.Body, maxRequestBodyBytes) - if token != "" { - r.Header.Set("Authorization", "Bearer "+token) - } - r.Header.Set("Content-Type", "application/json") - return r -} - -func TestBind_WithAtmosphere_Success(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - var calls atomic.Int64 - bindAtmo := func(_ context.Context, handle, appPassword string) (*atmosphereBinding, error) { - if handle != "status-example.bsky.social" || appPassword != "app-pass" { - t.Errorf("unexpected atmosphere args: %q / %q", handle, appPassword) - } - return &atmosphereBinding{ - Handle: handle, - DID: "did:plc:example", - PDSURL: "https://example.pds", - EncryptedPassword: "ENCRYPTED", - }, nil - } - h := bindMonitorHandler(context.Background(), secret, cli, fakeVerifier(true, &calls), 100, bindAtmo, nil) - - tok := issueTestToken(secret) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, bindReqWithAtmosphere(tok, "https://www.example.com/", "status-example.bsky.social", "app-pass")) - - if rec.Code != http.StatusCreated { - t.Fatalf("status = %d (%s); want 201", rec.Code, rec.Body.String()) - } - - mon, _ := cli.Get(context.Background(), monitorKey(tokenHash(tok))) - if mon.Count != 1 { - t.Fatal("monitor blob not written") - } - var blob monitorBlob - if err := json.Unmarshal(mon.Kvs[0].Value, &blob); err != nil { - t.Fatalf("unmarshal blob: %v", err) - } - if blob.Atmosphere == nil { - t.Fatal("blob.Atmosphere = nil; want populated") - } - if blob.Atmosphere.DID != "did:plc:example" { - t.Errorf("blob.Atmosphere.DID = %q; want did:plc:example", blob.Atmosphere.DID) - } - if blob.Atmosphere.EncryptedPassword != "ENCRYPTED" { - t.Errorf("EncryptedPassword = %q; want ENCRYPTED", blob.Atmosphere.EncryptedPassword) - } -} - -func TestBind_WithAtmosphere_PreparationFails_NoMonitor(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - var calls atomic.Int64 - bindAtmo := func(context.Context, string, string) (*atmosphereBinding, error) { - return nil, fmt.Errorf("atmosphere: account has existing posts; create a dedicated account") - } - h := bindMonitorHandler(context.Background(), secret, cli, fakeVerifier(true, &calls), 100, bindAtmo, nil) - - tok := issueTestToken(secret) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, bindReqWithAtmosphere(tok, "https://www.example.com/", "not-empty.bsky.social", "app-pass")) - - if rec.Code != http.StatusBadRequest { - t.Fatalf("status = %d (%s); want 400", rec.Code, rec.Body.String()) - } - if calls.Load() != 0 { - t.Errorf("verifier called %d times; want 0 — atmosphere failure must short-circuit before HEAD", calls.Load()) - } - - // No monitor written, counters untouched. - mon, _ := cli.Get(context.Background(), monitorKey(tokenHash(tok))) - if mon.Count != 0 { - t.Error("monitor blob written despite atmosphere failure") - } - statsResp, _ := cli.Get(context.Background(), keyStatsCount) - if statsResp.Count != 0 && string(statsResp.Kvs[0].Value) != "0" { - t.Errorf("stats/monitor_count = %q; want 0", statsResp.Kvs[0].Value) - } -} - -// TestBind_IdempotentRebind_PreservesAtmosphereCreatedAtHistory locks down -// the three invariants the sticky-rebind change introduced: -// - Atmosphere binding survives idempotent re-bind unchanged (matches -// openapi.json + CLAUDE.md: "Atmosphere cannot be changed after bind"). -// - CreatedAt persists for the life of the monitor (was being reset). -// - History ring buffer persists across re-binds (was being wiped). -// -// Also asserts that a re-bind request carrying an atmosphere block is -// rejected with 400 — silently dropping it would surprise callers who -// think they updated their credential. -func TestBind_IdempotentRebind_PreservesAtmosphereCreatedAtHistory(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - var calls atomic.Int64 - bindAtmo := func(_ context.Context, handle, _ string) (*atmosphereBinding, error) { - return &atmosphereBinding{ - Handle: handle, - DID: "did:plc:example", - PDSURL: "https://example.pds", - EncryptedPassword: "ENCRYPTED-V1", - }, nil - } - h := bindMonitorHandler(context.Background(), secret, cli, fakeVerifier(true, &calls), 100, bindAtmo, nil) - - tok := issueTestToken(secret) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, bindReqWithAtmosphere(tok, "https://www.example.com/", "status-example.bsky.social", "app-pass")) - if rec.Code != http.StatusCreated { - t.Fatalf("first bind: status = %d (%s); want 201", rec.Code, rec.Body.String()) - } - - ctx := context.Background() - tokHash := tokenHash(tok) - - // Plant a synthetic history entry + roll CreatedAt back so we can - // detect a wipe (`now` minus an hour is reliably distinguishable from - // a fresh CreatedAt within the test). - mon, _ := cli.Get(ctx, monitorKey(tokHash)) - var first monitorBlob - if err := json.Unmarshal(mon.Kvs[0].Value, &first); err != nil { - t.Fatalf("unmarshal first blob: %v", err) - } - originalCreatedAt := time.Now().UTC().Add(-time.Hour).Truncate(time.Second) - first.CreatedAt = originalCreatedAt - first.History = []observation{{HTTPStatus: 500, Error: "other", ObservedAt: originalCreatedAt.Add(15 * time.Minute)}} - patched, _ := json.Marshal(first) - if _, err := cli.Put(ctx, monitorKey(tokHash), string(patched)); err != nil { - t.Fatalf("plant patched blob: %v", err) - } - - // Re-bind without atmosphere — must succeed and preserve all three. - rec = httptest.NewRecorder() - h.ServeHTTP(rec, bindReq(tok, "https://www.example.com/v2")) - if rec.Code != http.StatusCreated { - t.Fatalf("rebind (no atmo): status = %d (%s); want 201", rec.Code, rec.Body.String()) - } - - mon, _ = cli.Get(ctx, monitorKey(tokHash)) - var after monitorBlob - if err := json.Unmarshal(mon.Kvs[0].Value, &after); err != nil { - t.Fatalf("unmarshal after blob: %v", err) - } - - if after.URL != "https://www.example.com/v2" { - t.Errorf("after.URL = %q; want https://www.example.com/v2 (URL must update)", after.URL) - } - if !after.CreatedAt.Equal(originalCreatedAt) { - t.Errorf("after.CreatedAt = %v; want %v (must persist across re-bind)", after.CreatedAt, originalCreatedAt) - } - if len(after.History) != 1 || after.History[0].HTTPStatus != 500 { - t.Errorf("after.History = %+v; want planted single entry preserved", after.History) - } - if after.Atmosphere == nil { - t.Fatal("after.Atmosphere = nil; want preserved") - } - if after.Atmosphere.EncryptedPassword != "ENCRYPTED-V1" { - t.Errorf("after.Atmosphere.EncryptedPassword = %q; want ENCRYPTED-V1 (sticky)", after.Atmosphere.EncryptedPassword) - } - - // Re-bind WITH atmosphere block — must 400, leaving the existing blob - // untouched. We don't strictly need the bindAtmo func to be wired for - // this case (the rejection happens before atmosphere prep) but we - // pass a different EncryptedPassword to prove it isn't taking effect. - bindAtmoV2 := func(_ context.Context, handle, _ string) (*atmosphereBinding, error) { - return &atmosphereBinding{Handle: handle, DID: "did:plc:other", PDSURL: "https://other.pds", EncryptedPassword: "ENCRYPTED-V2"}, nil - } - h2 := bindMonitorHandler(context.Background(), secret, cli, fakeVerifier(true, &calls), 100, bindAtmoV2, nil) - rec = httptest.NewRecorder() - h2.ServeHTTP(rec, bindReqWithAtmosphere(tok, "https://www.example.com/v3", "new-handle.bsky.social", "new-pass")) - if rec.Code != http.StatusBadRequest { - t.Fatalf("rebind (with atmo): status = %d (%s); want 400 (atmosphere is sticky)", rec.Code, rec.Body.String()) - } - - mon, _ = cli.Get(ctx, monitorKey(tokHash)) - var unchanged monitorBlob - _ = json.Unmarshal(mon.Kvs[0].Value, &unchanged) - if unchanged.URL != "https://www.example.com/v2" { - t.Errorf("rejected re-bind mutated URL: %q", unchanged.URL) - } - if unchanged.Atmosphere == nil || unchanged.Atmosphere.EncryptedPassword != "ENCRYPTED-V1" { - t.Errorf("rejected re-bind mutated atmosphere: %+v", unchanged.Atmosphere) - } -} - -func TestTokenHash_DifferentTokensProduceDifferentHashes(t *testing.T) { - a := tokenHash("pa_alpha") - b := tokenHash("pa_beta") - if a == b { - t.Fatal("distinct tokens hashed to identical values") - } - if len(a) != 64 { - t.Errorf("hash length = %d; want 64 (sha256 hex)", len(a)) - } - if _, err := hex.DecodeString(a); err != nil { - t.Errorf("hash %q is not hex: %v", a, err) - } -} diff --git a/openapi.go b/openapi.go deleted file mode 100644 index ad88683..0000000 --- a/openapi.go +++ /dev/null @@ -1,75 +0,0 @@ -package main - -import ( - _ "embed" - "io" - "net/http" - "strings" -) - -// openapi.json is the canonical API spec. Embedded so the binary serves it -// directly without a filesystem dep. Tests assert it parses, generates a -// compilable Go client, and lists exactly the routes registered in main.go -// (see openapi_test.go). -// -//go:embed openapi.json -var openapiSpec []byte - -// ogImage is a 1000x1000 JPEG referenced by openapiHTML's og:image tag so -// link-preview cards render with a thumbnail. Placeholder — copied from -// divepool until pingarthur gets its own brand asset. -// -//go:embed assets/og-image.jpg -var ogImage []byte - -// openapiHTML is returned to social-media link-preview bots so a link to -// /api/v1/openapi renders with proper OG metadata instead of dumping JSON -// into the preview card. Same trick divepool uses. -const openapiHTML = ` - - - -PingArthur API - - - - - - - - - - - -` - -// serveOpenAPI returns the embedded spec as JSON, except for known -// link-preview bots which get the OG-tagged HTML instead. -func serveOpenAPI(w http.ResponseWriter, r *http.Request) { - ua := r.Header.Get("User-Agent") - if strings.Contains(ua, "Cardyb") || strings.Contains(ua, "Twitterbot") || strings.Contains(ua, "facebookexternalhit") { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Header().Set("Cache-Control", "public, max-age=300") - _, _ = io.WriteString(w, openapiHTML) - return - } - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Cache-Control", "public, max-age=300") - _, _ = w.Write(openapiSpec) -} - -// serveOpenAPIHTML always returns the OG-tagged HTML, regardless of UA. -// Useful for sharing a renderable URL. -func serveOpenAPIHTML(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Header().Set("Cache-Control", "public, max-age=300") - _, _ = io.WriteString(w, openapiHTML) -} - -// serveOpenAPIImage returns the embedded og:image asset, referenced from -// openapiHTML so link-preview cards render with a thumbnail. -func serveOpenAPIImage(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "image/jpeg") - w.Header().Set("Cache-Control", "public, max-age=86400") - _, _ = w.Write(ogImage) -} diff --git a/openapi.json b/openapi.json deleted file mode 100644 index a9e0709..0000000 --- a/openapi.json +++ /dev/null @@ -1,454 +0,0 @@ -{ - "openapi": "3.0.3", - "info": { - "title": "PingArthur", - "version": "0.0.0-pre-alpha", - "description": "Minimal uptime monitoring. Prove URL ownership with a header echo, then\npoll for the latest status. See README.md for context, CLAUDE.md for the\narchitecture, BACKLOG.md for parked features.\n\n**This document is canonical for limits, timeouts, and endpoint shapes.**\nCode constants must match it; if they drift, fix the code, not the spec.\n\n**End-to-end flow.** The header echo is both the ownership proof at bind\ntime *and* the ongoing liveness signal — there is no separate \"verify\nonce, then trust\" phase.\n\n1. `POST /api/v1/tokens` → receive a `pa_<...>` bearer.\n2. **Configure the target first.** Make every HEAD response from the URL\n you intend to monitor return an `X-PingArthur-Token` header whose\n value equals the bearer from step 1, and deploy that change before\n step 3. The header value is the bearer verbatim — no `Bearer ` prefix,\n no JSON wrapping, no transformation. Same value goes back on every\n HEAD response forever; it is not a one-shot challenge.\n3. `POST /api/v1/monitors` with the bearer in `Authorization` and the\n target URL in the body. The server immediately HEAD-probes the URL\n and records the result. If you skipped step 2, the monitor is still\n created — see `bindMonitor` for the `token_mismatch` grace window.\n4. `GET /api/v1/status` (same bearer) returns the latest observation.\n5. **Steady state:** keep returning the `X-PingArthur-Token: `\n header on every HEAD response, indefinitely. The 60-minute checker\n re-validates the echo on every cycle; 24 h with the echo missing or\n mismatched auto-stops and deletes the monitor.\n\n**Stopping monitoring / deleting your data.** The protocol is the\ndeletion API: remove the `X-PingArthur-Token` header from your URL's HEAD\nresponse, and within 24 h the monitor and all stored data — including\nthe encrypted Atmosphere app password, if any — are deleted from etcd\n(no backups). There is no separate `DELETE` endpoint by design: the\nbearer token is a capability URL, intentionally non-secret, so granting\nit delete authority would let anyone who saw the polling URL evict the\nmonitor. URL-echo ownership is the trust we honor.\n\n**Operator-only surfaces (deliberately out of scope):**\n`PATCH /admin/v1/monitors` and `GET /metrics` are served by the pingarthur\nbinary but aren't reverse-proxied to the public internet — reachable only\nfrom inside a node (SSH moat, or tailscale0 for `/metrics` on port 9090).\nSee `admin.go` and `supervision.go` for details; don't generate clients\nagainst them from this spec.\n\n**Ambient limits.** Encoded here because OpenAPI 3.1 has no first-class\nsection for \"ambient\" limits; canonical values live in `main.go` (server)\nand `verify.go` (probe).\n\nInbound (HTTP server):\n- read + idle timeout: 10 s\n- write timeout: 15 s (> outbound probe's 10 s deadline, so a timed-out\n verify has headroom to respond)\n- request body: 4 KiB (over → 413; bounds BindRequest.url)\n- request headers: 16 KiB\n\nOutbound (HEAD probe of monitored URL):\n- total request deadline: 10 s\n- DNS resolution: 3 s\n- TLS handshake: 3 s\n- response-header read deadline: 5 s\n- response-header total size: 16 KiB (over → rejected as \"other\"; defends\n against header floods)\n- redirects followed: never (3xx surfaces as the recorded status)\n- IP family: IPv4 only\n- blocked CIDRs: 127/8, 10/8, 172.16/12, 192.168/16, 169.254/16, 100.64/10,\n 0/8, 224/4, 240/4\n- X-Health-Status retained: first 280 runes, valid UTF-8 only (raw bytes\n scrubbed of invalid sequences before truncation). Cap is rune-based so\n the recorded value is drop-in postable to a Bluesky post (which is\n bounded by 300 graphemes / 3000 bytes, and runes ≥ graphemes for any\n string)\n- X-PingArthur-Token compared: constant-time, never logged or stored\n- User-Agent: PingArthur/1.0 (+https://pingarthur.com)\n\nCaps:\n- monitors per eTLD+1: 10\n- global monitors: MAX_MONITORS env, default 10000\n- token bind window: 1 h after issuance\n- default check interval: 60 min (one-shot random offset at bind, then on a\n fixed grid)\n- auto-stop after token-header gone: 24 h\n- change-history ring buffer per monitor: N=10\n", - "license": { - "name": "MIT", - "url": "https://opensource.org/licenses/MIT" - }, - "termsOfService": "https://divepool.social/terms", - "contact": { - "name": "PingArthur", - "url": "https://bsky.app/profile/pingarthur.com" - } - }, - "externalDocs": { - "description": "Source repository on Tangled", - "url": "https://tangled.org/divepool.social/pingarthur" - }, - "servers": [ - { - "url": "https://pingarthur.com", - "description": "public instance" - } - ], - "tags": [ - { - "name": "tokens", - "description": "Bearer token issuance and binding." - }, - { - "name": "status", - "description": "Monitor status retrieval." - }, - { - "name": "ops", - "description": "Operational endpoints (health, supervision)." - }, - { - "name": "spec", - "description": "OpenAPI spec self-serve." - } - ], - "paths": { - "/api/v1/tokens": { - "post": { - "tags": [ - "tokens" - ], - "operationId": "issueToken", - "summary": "Issue a fresh bearer token", - "description": "Returns a stateless `pa_` HMAC token. Valid for 1 hour as\na *bind* credential — after that it can no longer be presented to\n`POST /api/v1/monitors`. Once bound, the token's hash becomes the\nmonitor identity and stays usable indefinitely against\n`GET /api/v1/status`.\n\n**Next step:** before calling `POST /api/v1/monitors`, configure the\ntarget URL to return the bearer verbatim in an `X-PingArthur-Token`\nheader on every HEAD response, and keep that header in place for the\nlifetime of the monitor. Deploying after the bind call works too\n(`token_mismatch` grace window — see `bindMonitor`), but is more\nfragile.\n\nNo request body is read. Bodies up to the global limit are accepted\nbut ignored.\n", - "responses": { - "200": { - "description": "Token issued.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TokenResponse" - } - } - } - } - } - } - }, - "/api/v1/monitors": { - "post": { - "tags": [ - "tokens" - ], - "operationId": "bindMonitor", - "summary": "Bind a URL to the bearer token", - "description": "HEAD-probes the URL and records whether `X-PingArthur-Token`\nechoes the bearer, then starts checking on a 60-minute cadence.\n\n**Pre-requisite:** the target URL should already be returning\n`X-PingArthur-Token: ` (bearer value verbatim, no\n`Bearer ` prefix) on every HEAD response *before* this call.\nThe checker treats the echo as the ongoing liveness signal —\nit is not a one-shot challenge. The header must remain present\non every HEAD response for the life of the monitor; 24 h with\nit missing or mismatched auto-stops and deletes the monitor\n(see top-level description, *Stopping monitoring*).\n\n**Token mismatch at bind is accepted** — the monitor is still\ncreated with `current.error = \"token_mismatch\"` so callers can\nwire up the echo header after binding. The 60-minute check loop\nrevalidates; if the echo never comes online within 24 h of bind,\nthe monitor auto-stops. Other HEAD failures (DNS, TLS, blocked,\ntimeout, etc.) reject with 400 — those indicate the URL itself\nis wrong or unreachable.\n\nThe first check is scheduled at `now + random offset in [0, 5 s)`;\nevery subsequent check lands on the fixed grid\n`scheduled + interval` so monitors stay phase-locked to the\nbind moment. Operators who want a specific phase (e.g. mid-minute\nfor a target with minute-aligned behaviour) bind at the desired\nwall-clock time — the 5 s jitter is small enough that bind moment\ndominates phase.\n\n**Idempotent re-bind** (within the 1 h bind window of the\noriginal token, while a monitor exists for it): updates the URL\nand reschedules. `created_at` and the change-history ring buffer\nare preserved for the life of the monitor. The atmosphere\nbinding is sticky after the first bind — re-bind requests that\ninclude an `atmosphere` block are rejected with 400. To swap\naccounts, stop echoing the token (24 h auto-stop deletes the\nmonitor and the encrypted credential), then re-bind from a fresh\ntoken.\n\nIf an `atmosphere` object is included on a fresh bind, a status\npost is sent to the bound account capturing the current\nobservation tuple — including `token_mismatch` if the echo\nisn't up yet.\n", - "security": [ - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BindRequest" - } - } - } - }, - "responses": { - "201": { - "description": "Monitor bound (may be pending token echo — see description).", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BindResponse" - } - } - } - }, - "400": { - "description": "Invalid URL (non-HTTPS, bare IP, malformed), or non-token-mismatch verify failure (dns/tls/blocked/timeout/refused/other), or atmosphere setup failed (empty-repo check, bad app password, unresolvable handle), or atmosphere block supplied on idempotent re-bind (atmosphere is sticky after first bind)." - }, - "401": { - "description": "Missing or invalid bearer token." - }, - "413": { - "description": "Request body exceeds the 4 KiB limit." - }, - "429": { - "description": "Per-domain cap (≥10 active monitors per eTLD+1) or global cap reached." - }, - "503": { - "description": "Bind-pending lease (~30 s TTL) expired mid-verify. Should not\nhappen under the 10 s outbound probe deadline; safe to retry.\n" - } - } - } - }, - "/api/v1/status": { - "get": { - "tags": [ - "status" - ], - "operationId": "getMonitorStatus", - "summary": "Latest observation + recent change history", - "description": "Returns the most recent observation plus up to 10 recent status\nchanges. **Assume this data is public** — anyone with the bearer\ntoken can fetch it.\n", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Current state.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StatusResponse" - } - } - } - }, - "401": { - "description": "Missing or invalid bearer token." - }, - "404": { - "description": "No monitor bound to this token (or auto-stopped after 24 h of missing header echo)." - } - } - } - }, - "/status": { - "get": { - "tags": [ - "ops" - ], - "operationId": "healthcheck", - "summary": "Supervisor health probe (no auth)", - "description": "Returns 200 if the local etcd member is reachable from this node,\n503 otherwise. Polled by Oh Dear (external) and used by the DNS\nround-robin clients to evict dead nodes.\n", - "responses": { - "200": { - "description": "Local node healthy.", - "content": { - "text/plain": { - "schema": { - "type": "string", - "example": "ok\n" - } - } - } - }, - "503": { - "description": "Local etcd unreachable." - } - } - } - }, - "/api/v1/openapi": { - "get": { - "tags": [ - "spec" - ], - "operationId": "getOpenAPI", - "summary": "This OpenAPI document", - "description": "Returns the embedded OpenAPI spec as JSON. Link-preview bots\n(Twitterbot, Cardyb, facebookexternalhit) get an HTML page with\nOpen Graph tags instead so a shared link renders nicely.\n", - "responses": { - "200": { - "description": "Spec document.", - "content": { - "application/json": { - "schema": { - "type": "object", - "description": "OpenAPI 3.0 document." - } - }, - "text/html": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/api/v1/openapi.json": { - "get": { - "tags": [ - "spec" - ], - "operationId": "getOpenAPIJSON", - "summary": "This OpenAPI document (JSON, no UA sniffing)", - "responses": { - "200": { - "description": "Spec document.", - "content": { - "application/json": { - "schema": { - "type": "object", - "description": "OpenAPI 3.0 document." - } - } - } - } - } - } - }, - "/api/v1/openapi.html": { - "get": { - "tags": [ - "spec" - ], - "operationId": "getOpenAPIHTML", - "summary": "Open-Graph preview page for /api/v1/openapi", - "responses": { - "200": { - "description": "OG-tagged HTML.", - "content": { - "text/html": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/api/v1/openapi.jpg": { - "get": { - "tags": [ - "spec" - ], - "operationId": "getOpenAPIImage", - "summary": "Open-Graph thumbnail referenced by /api/v1/openapi.html", - "responses": { - "200": { - "description": "JPEG image, 1000x1000.", - "content": { - "image/jpeg": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - } - } - } - } - }, - "components": { - "securitySchemes": { - "bearerAuth": { - "type": "http", - "scheme": "bearer", - "bearerFormat": "pa_" - } - }, - "schemas": { - "TokenResponse": { - "type": "object", - "required": [ - "token", - "expires_at" - ], - "properties": { - "token": { - "type": "string", - "example": "pa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" - }, - "expires_at": { - "type": "string", - "format": "date-time", - "description": "Bind-window expiry (now + 1h)." - } - } - }, - "BindRequest": { - "type": "object", - "required": [ - "url" - ], - "properties": { - "url": { - "type": "string", - "format": "uri", - "maxLength": 4000, - "description": "HTTPS URL of the resource to monitor. Bare IPs are rejected.\n**Length cap is a derivative of the 4 KiB request-body limit**:\nanything over the body cap is refused with 413 before parsing,\nso the JSON envelope leaves a few hundred bytes of headroom over\nthis maxLength. The explicit cap is here to make the limit\ndiscoverable from the spec.\n", - "example": "https://www.example.com/" - }, - "atmosphere": { - "$ref": "#/components/schemas/AtmosphereBindRequest" - } - } - }, - "AtmosphereBindRequest": { - "type": "object", - "required": [ - "handle", - "app_password" - ], - "description": "Optional. Attach an ATProto account so each observation-tuple\nchange is posted as a Bluesky post (`app.bsky.feed.post` record)\nto the account's repo. Posts render in the Bluesky app and any\nAppView that ingests Bluesky lexicons. The PDS itself can be\nBluesky's `bsky.social` or any conforming self-hosted PDS — the\nauth and write calls are ATProto-standard.\n\n**Post body is your `X-Health-Status` header verbatim.** No\nmonitor URL, no diff, no decoration — whatever the target\nreturned in `X-Health-Status` (after the 280-rune cap) is the\npost text. This makes the message fully customizable by the\ntarget itself; if you want context, put it in the header.\n\nWhen the header is absent (network error, timeout, status-only\nchanges, or a healthy target that hasn't set `X-Health-Status`),\nPingArthur posts a descriptive fallback that names what failed\nand whether it's user-fixable, persistent, or transient — enough\ncontext that an operator reading the post on Bluesky can decide\nto debug their config or wait it out. Bluesky requires non-empty\npost text, so the fallback always produces something postable.\n\n**Hidden tags** (`app.bsky.feed.post.tags`) are attached to\nevery post: `PingArthur` (lets anyone filter all posts from\nthis service) and the monitor's token-hash (lets a viewer\ngroup posts per-monitor without leaking the bearer — the hash\nis SHA-256 of the bearer, irreversible). Tags are metadata,\nnot rendered inline in post text.\n\n**Use a dedicated account.** The bind rejects (400) if the\naccount's `app.bsky.feed.post` collection is non-empty, to\nprotect users from accidentally linking their main identity.\n\nCredentials verified at bind time via\n`com.atproto.server.createSession`. App passwords are encrypted\nat rest with AES-256-GCM; the server never returns the password\nor its ciphertext. **The atmosphere binding cannot be changed\nafter bind — to swap accounts, delete the monitor and re-bind\nfrom a fresh token.**\n", - "properties": { - "handle": { - "type": "string", - "maxLength": 253, - "description": "ATProto handle (e.g. `status-foo.bsky.social` or a custom\ndomain). Resolved to DID at bind time via the public\nappview; stored as DID + resolved PDS URL.\n", - "example": "status-example.bsky.social" - }, - "app_password": { - "type": "string", - "maxLength": 128, - "writeOnly": true, - "description": "App password for the account. **Do not use your main\naccount password.** On Bluesky, generate one at Settings →\nApp Passwords (ideally without DM access); they have the\nform `xxxx-xxxx-xxxx-xxxx`. On other PDSes, use whichever\ncredential the account exposes for\n`com.atproto.server.createSession` — the server-side check\nis the standard ATProto session call, no Bluesky-specific\nformat is required.\n" - } - } - }, - "BindResponse": { - "type": "object", - "required": [ - "url", - "next_check_at" - ], - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "next_check_at": { - "type": "string", - "format": "date-time", - "description": "When the next check is scheduled. On a fresh bind this is at\n`now + random offset in [0, 5 s)`. On idempotent re-bind it\nstays on the original phase grid.\n" - } - } - }, - "Observation": { - "type": "object", - "required": [ - "http_status", - "health_status", - "error", - "observed_at" - ], - "properties": { - "http_status": { - "type": "integer", - "description": "HTTP status code from the HEAD response (0 if no response)." - }, - "health_status": { - "type": "string", - "description": "Value of the target's `X-Health-Status` response header, scrubbed\nof invalid UTF-8 and truncated to the first 280 runes (worst-case\n1120 bytes for 4-byte runes). **Targets should put the most\nimportant information first** — anything beyond rune 280 is\ndropped before we ever record it. The cap is sized so the\nrecorded value is drop-in postable to a Bluesky post when an\nAtmosphere binding is attached (Bluesky posts are bounded by\n300 graphemes / 3000 bytes; runes ≥ graphemes always).\n" - }, - "error": { - "type": "string", - "enum": [ - "", - "token_mismatch", - "blocked", - "dns", - "tls", - "timeout", - "refused", - "other" - ], - "description": "Normalized error category. Empty on success.\n\n- `` (empty): HEAD returned a response *and* `X-PingArthur-Token` matched the bearer.\n- `token_mismatch`: HEAD returned a response, but the echo header was missing or wrong. Only category that does **not** reject at bind; counts toward the 24 h auto-stop clock.\n- `blocked`: hostname resolved to a private/loopback/CGNAT/multicast IP — see SSRF blocklist in the top-level description.\n- `dns`: hostname did not resolve (NXDOMAIN, no IPv4 record, etc.).\n- `tls`: TLS handshake failed (bad cert, hostname mismatch, protocol error).\n- `timeout`: probe exceeded the 10 s deadline somewhere along the way.\n- `refused`: TCP connection refused.\n- `other`: anything else (response-header size cap exceeded, malformed HTTP, etc.).\n" - }, - "observed_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp of the most recent probe. For `current`, this\nadvances on every check — even when the observation tuple\ndidn't change — so polling clients can tell the monitor\nis still alive. For `history` entries, it's the time of\nthe observation as it stood when it was replaced.\n" - } - } - }, - "StatusResponse": { - "type": "object", - "required": [ - "url", - "current", - "history" - ], - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "current": { - "$ref": "#/components/schemas/Observation" - }, - "history": { - "type": "array", - "maxItems": 10, - "description": "Up to 10 most recent observations where the (status, health, error) tuple changed. Newest first.", - "items": { - "$ref": "#/components/schemas/Observation" - } - }, - "atmosphere": { - "$ref": "#/components/schemas/AtmosphereStatus" - } - } - }, - "AtmosphereStatus": { - "type": "object", - "description": "Present only if an Atmosphere binding was attached at bind time.\nDisplay-only view — the server **never** returns the app password\nor its encrypted form. `last_post_error` is surfaced so clients\ncan tell whether posting is currently failing without having to\nscrape Prometheus.\n", - "required": [ - "handle" - ], - "properties": { - "handle": { - "type": "string", - "description": "The handle supplied at bind (may have since been renamed by the user on Bluesky; the DID is the stable identity we post against)." - }, - "last_post_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp of the most recent successful atmosphere post; absent if none have succeeded yet." - }, - "last_post_error": { - "type": "string", - "maxLength": 200, - "description": "Short description of the most recent post failure. Absent on success." - }, - "last_post_error_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp of the last_post_error. Absent if last_post_error is absent." - } - } - } - } - } -} diff --git a/openapi_test.go b/openapi_test.go deleted file mode 100644 index 39233b9..0000000 --- a/openapi_test.go +++ /dev/null @@ -1,134 +0,0 @@ -package main - -import ( - "context" - "go/parser" - "go/token" - "os" - "regexp" - "sort" - "strings" - "testing" - - "github.com/getkin/kin-openapi/openapi3" - "github.com/oapi-codegen/oapi-codegen/v2/pkg/codegen" -) - -const openAPIPath = "openapi.json" - -// TestOpenAPI_Valid catches malformed JSON, undefined $refs, missing required -// fields, and other spec-level breakage. Runs in milliseconds. -func TestOpenAPI_Valid(t *testing.T) { - loader := openapi3.NewLoader() - loader.IsExternalRefsAllowed = false - - doc, err := loader.LoadFromFile(openAPIPath) - if err != nil { - t.Fatalf("load %s: %v", openAPIPath, err) - } - if err := doc.Validate(context.Background()); err != nil { - t.Fatalf("validate %s: %v", openAPIPath, err) - } -} - -// TestOpenAPI_GeneratesGoClient runs oapi-codegen against the spec and -// confirms (a) generation succeeds, (b) the result parses as Go, and (c) -// every operationId surfaces as a generated function. Failure means the -// spec drifted into a shape downstream consumers can't compile against. -func TestOpenAPI_GeneratesGoClient(t *testing.T) { - doc, err := openapi3.NewLoader().LoadFromFile(openAPIPath) - if err != nil { - t.Fatalf("load %s: %v", openAPIPath, err) - } - - code, err := codegen.Generate(doc, codegen.Configuration{ - PackageName: "pingarthurclient", - Generate: codegen.GenerateOptions{ - Client: true, - Models: true, - }, - }) - if err != nil { - t.Fatalf("codegen.Generate: %v", err) - } - if len(code) == 0 { - t.Fatal("codegen produced empty output") - } - - fset := token.NewFileSet() - if _, err := parser.ParseFile(fset, "generated.go", code, parser.AllErrors); err != nil { - t.Fatalf("generated client doesn't parse as Go: %v\n--- generated ---\n%s", err, code) - } - - // oapi-codegen capitalizes operationIds; one function per operation. - want := []string{"IssueToken", "BindMonitor", "GetMonitorStatus", "Healthcheck", "GetOpenAPI", "GetOpenAPIJSON", "GetOpenAPIHTML", "GetOpenAPIImage"} - for _, fn := range want { - if !strings.Contains(code, fn) { - t.Errorf("generated client missing operation %q", fn) - } - } -} - -// TestOpenAPI_RoutesMatchSpec asserts every public route registered on the -// app's main mux appears in openapi.json (and vice versa). Catches drift -// when a route is added, renamed, or removed without a matching spec edit. -// -// Operator-only surfaces are deliberately out of the spec (see CLAUDE.md -// "Key surfaces" + the openapi.json info.description block) and are -// filtered here so the test doesn't flag them as missing. -func TestOpenAPI_RoutesMatchSpec(t *testing.T) { - src, err := os.ReadFile("main.go") - if err != nil { - t.Fatalf("read main.go: %v", err) - } - // Matches `mux.Handle("METHOD /path", ...)`. Metrics live on a separate - // `metricsMux` and don't match this prefix, which is what we want. - re := regexp.MustCompile(`\bmux\.Handle\("(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS) ([^"]+)"`) - app := map[string]bool{} - for _, m := range re.FindAllStringSubmatch(string(src), -1) { - app[m[1]+" "+m[2]] = true - } - if len(app) == 0 { - t.Fatal("no mux.Handle calls found in main.go — route extraction regex is stale") - } - // Operator-only — out of public spec scope by design. - delete(app, "PATCH /admin/v1/monitors") - // Homepage is HTML, not part of the JSON API client contract. - delete(app, "GET /{$}") - // SEO/discoverability surfaces (see seo.go) — static files for crawlers, - // not part of the JSON API client contract. - delete(app, "GET /robots.txt") - delete(app, "GET /sitemap.xml") - delete(app, "GET /llms.txt") - - doc, err := openapi3.NewLoader().LoadFromFile(openAPIPath) - if err != nil { - t.Fatalf("load %s: %v", openAPIPath, err) - } - spec := map[string]bool{} - for path, item := range doc.Paths.Map() { - for method := range item.Operations() { - spec[method+" "+path] = true - } - } - - var missingFromSpec, missingFromApp []string - for r := range app { - if !spec[r] { - missingFromSpec = append(missingFromSpec, r) - } - } - for r := range spec { - if !app[r] { - missingFromApp = append(missingFromApp, r) - } - } - sort.Strings(missingFromSpec) - sort.Strings(missingFromApp) - for _, r := range missingFromSpec { - t.Errorf("route %q registered in main.go but absent from %s", r, openAPIPath) - } - for _, r := range missingFromApp { - t.Errorf("route %q in %s but not registered in main.go", r, openAPIPath) - } -} diff --git a/safehttp.go b/safehttp.go deleted file mode 100644 index 4dcfdc6..0000000 --- a/safehttp.go +++ /dev/null @@ -1,136 +0,0 @@ -package main - -import ( - "context" - "crypto/tls" - "errors" - "fmt" - "net" - "net/http" - "time" -) - -// errSSRFBlocked is returned whenever the dialer refuses a target because its -// IP falls in a private/reserved range or the host only has IPv6 records. -// Propagated to checker logic as the "blocked" normalized error category. -var errSSRFBlocked = errors.New("ssrf_blocked") - -// blockedCIDRs covers loopback, private ranges (RFC 1918), link-local, CGNAT -// (which also houses Tailscale), any-address, multicast, and reserved future -// ranges. See CLAUDE.md "Security — SSRF dialer". -var blockedCIDRs = mustParseCIDRs([]string{ - "127.0.0.0/8", - "10.0.0.0/8", - "172.16.0.0/12", - "192.168.0.0/16", - "169.254.0.0/16", - "100.64.0.0/10", - "0.0.0.0/8", - "224.0.0.0/4", - "240.0.0.0/4", -}) - -func mustParseCIDRs(raw []string) []*net.IPNet { - out := make([]*net.IPNet, 0, len(raw)) - for _, c := range raw { - _, n, err := net.ParseCIDR(c) - if err != nil { - panic(fmt.Sprintf("bad CIDR %q: %v", c, err)) - } - out = append(out, n) - } - return out -} - -// isBlockedIP returns true for any non-IPv4 address and any IPv4 that lands -// in a blocked CIDR. IPv6 is blocked wholesale — v0 is IPv4 only. -// -// Dev mode (insecureDevMode) skips the CIDR check so the local Procfile and -// e2e test can reach 127.0.0.1; IPv6 stays blocked regardless. -func isBlockedIP(ip net.IP) bool { - ip4 := ip.To4() - if ip4 == nil { - return true - } - if insecureDevMode() { - return false - } - for _, n := range blockedCIDRs { - if n.Contains(ip4) { - return true - } - } - return false -} - -// safeDialContext resolves the host to IPv4, validates *every* answer against -// the blocklist (so a mixed A-record set can't smuggle a private address -// past us), then dials the literal IP — closing the DNS-rebind gap between -// resolution and connect. -func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) { - host, port, err := net.SplitHostPort(addr) - if err != nil { - return nil, err - } - - if literal := net.ParseIP(host); literal != nil { - if isBlockedIP(literal) { - return nil, errSSRFBlocked - } - return (&net.Dialer{Timeout: 3 * time.Second}).DialContext(ctx, "tcp4", addr) - } - - resCtx, cancel := context.WithTimeout(ctx, 3*time.Second) - defer cancel() - ips, err := net.DefaultResolver.LookupIP(resCtx, "ip4", host) - if err != nil { - return nil, err - } - if len(ips) == 0 { - // Resolver returned no error and no IPs — synthesize a DNS error so - // classifyNetErr lands on "dns" rather than the misleading "blocked". - return nil, &net.DNSError{Err: "no ipv4 records", Name: host, IsNotFound: true} - } - for _, ip := range ips { - if isBlockedIP(ip) { - return nil, errSSRFBlocked - } - } - return (&net.Dialer{Timeout: 3 * time.Second}).DialContext(ctx, "tcp4", net.JoinHostPort(ips[0].String(), port)) -} - -// maxResponseHeaderBytes caps the headers a target can ship back to us. -// Mirrors the inbound cap on our own server (maxRequestHeaderBytes). Go's -// http.Transport defaults to 1 MB if unset — uncapped relative to our -// workload, and trivially weaponisable (32 concurrent scans × 1 MB). -const maxResponseHeaderBytes = 16 << 10 // 16 KiB - -// newSafeHTTPClient returns the shared outbound client for health checks. -// No redirects (CheckRedirect returns ErrUseLastResponse so the 3xx surfaces -// as the recorded status), 10s total deadline, 3s TLS handshake, IPv4 only. -// Outbound response-header size is capped at 16 KiB so a misbehaving target -// can't pressure us with multi-megabyte header floods. -// -// Dev mode (insecureDevMode) additionally trusts httptest's self-signed -// certs by setting InsecureSkipVerify — paired with the dialer relaxation -// above, this lets the local Procfile + e2e test reach a real local TLS -// server. Production builds (no `dev` tag) can never enter this branch. -func newSafeHTTPClient() *http.Client { - transport := &http.Transport{ - DialContext: safeDialContext, - TLSHandshakeTimeout: 3 * time.Second, - ResponseHeaderTimeout: 5 * time.Second, - MaxResponseHeaderBytes: maxResponseHeaderBytes, - DisableKeepAlives: true, - } - if insecureDevMode() { - transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // dev-only: see dev_mode_dev.go - } - return &http.Client{ - Timeout: 10 * time.Second, - CheckRedirect: func(*http.Request, []*http.Request) error { - return http.ErrUseLastResponse - }, - Transport: transport, - } -} diff --git a/safehttp_test.go b/safehttp_test.go deleted file mode 100644 index c7f9581..0000000 --- a/safehttp_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package main - -import ( - "context" - "crypto/tls" - "errors" - "net" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" -) - -func TestIsBlockedIP(t *testing.T) { - cases := []struct { - ip string - blocked bool - }{ - {"127.0.0.1", true}, - {"127.255.255.254", true}, - {"10.0.0.1", true}, - {"10.255.255.254", true}, - {"172.16.0.1", true}, - {"172.31.255.254", true}, - {"172.32.0.1", false}, // just past the /12 - {"192.168.1.1", true}, - {"169.254.169.254", true}, // AWS/GCP metadata - {"100.64.0.1", true}, // CGNAT / Tailscale - {"100.127.255.254", true}, - {"100.128.0.1", false}, // just past the /10 - {"0.0.0.0", true}, - {"224.0.0.1", true}, // multicast - {"239.255.255.255", true}, - {"240.0.0.1", true}, // reserved - {"8.8.8.8", false}, - {"1.1.1.1", false}, - {"142.250.1.1", false}, // public google - {"::1", true}, // IPv6 loopback blocked wholesale - {"2606:4700::1", true}, // IPv6 public blocked wholesale - } - for _, c := range cases { - ip := net.ParseIP(c.ip) - if ip == nil { - t.Fatalf("ParseIP(%q) = nil", c.ip) - } - if got := isBlockedIP(ip); got != c.blocked { - t.Errorf("isBlockedIP(%s) = %v; want %v", c.ip, got, c.blocked) - } - } -} - -func TestSafeDialContext_RejectsBlockedLiterals(t *testing.T) { - blocked := []string{ - "127.0.0.1:443", - "10.0.0.1:443", - "172.20.0.1:443", - "192.168.1.1:443", - "169.254.169.254:80", - "100.64.0.1:443", - "[::1]:443", - "[2606:4700::1]:443", - } - for _, addr := range blocked { - _, err := safeDialContext(context.Background(), "tcp", addr) - if !errors.Is(err, errSSRFBlocked) { - t.Errorf("safeDialContext(%s) err = %v; want errSSRFBlocked", addr, err) - } - } -} - -// TestSafeHTTPClient_HeadLiteralLoopback is the step-3 Done criterion: -// the shared client must reject a HEAD to https://127.0.0.1/ at the dialer -// before any TCP connect attempt. -func TestSafeHTTPClient_HeadLiteralLoopback(t *testing.T) { - client := newSafeHTTPClient() - resp, err := client.Head("https://127.0.0.1/") - if err == nil { - resp.Body.Close() - t.Fatal("HEAD https://127.0.0.1/ succeeded; want ssrf_blocked error") - } - if !errors.Is(err, errSSRFBlocked) { - t.Fatalf("HEAD https://127.0.0.1/ err = %v; want errSSRFBlocked wrapped", err) - } -} - -// TestSafeHTTPClient_RejectsOversizedHeaders asserts our cap on response -// header size — a target that returns >16 KiB of headers must fail the -// request rather than load it all into memory. We don't care which exact -// error category Go's http.Transport surfaces; we do care that the call -// errors out cleanly. -func TestSafeHTTPClient_RejectsOversizedHeaders(t *testing.T) { - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // 32 KiB of junk headers — comfortably over our 16 KiB cap. - // Use many keys rather than one giant value so we test the - // aggregate size path, not just per-header length. - payload := strings.Repeat("x", 256) - for i := range 128 { - w.Header().Set("X-Junk-"+strings.Repeat("A", 4)+string(rune('a'+(i%26)))+string(rune('a'+(i/26))), payload) - } - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - - // Build a client with the same transport settings as the production - // client, but with the test cert trusted (we're not testing TLS - // trust here — we're testing the header-size cap). - client := &http.Client{ - Timeout: 5 * time.Second, - Transport: &http.Transport{ - TLSHandshakeTimeout: 3 * time.Second, - ResponseHeaderTimeout: 5 * time.Second, - MaxResponseHeaderBytes: maxResponseHeaderBytes, - DisableKeepAlives: true, - TLSClientConfig: &tls.Config{RootCAs: srv.Client().Transport.(*http.Transport).TLSClientConfig.RootCAs}, - }, - } - resp, err := client.Head(srv.URL) - if err == nil { - resp.Body.Close() - t.Fatalf("oversized-header target succeeded; want error") - } - // Expected error shape: http.Transport reports "server response - // headers exceeded %d bytes". Accept any non-nil error — the - // specific message is implementation detail. - if !strings.Contains(err.Error(), "exceeded") && !strings.Contains(err.Error(), "too large") { - t.Logf("got err = %v (not asserting on exact message)", err) - } -} diff --git a/seo.go b/seo.go deleted file mode 100644 index 78f2dc0..0000000 --- a/seo.go +++ /dev/null @@ -1,64 +0,0 @@ -package main - -import ( - "io" - "net/http" -) - -// robotsTxt allows all crawlers. PingArthur exposes only public surfaces -// (homepage + OpenAPI spec); there's nothing to hide from indexers. The -// Sitemap pointer is what Google Search Console / Bing Webmaster look for. -const robotsTxt = `User-agent: * -Allow: / - -Sitemap: https://pingarthur.com/sitemap.xml -` - -// sitemapXML lists the indexable URLs. The .json/.html/.jpg spec variants -// are deliberately omitted — the canonical entry point is /api/v1/openapi -// (UA-sniffed; see openapi.go). -const sitemapXML = ` - - - https://pingarthur.com/ - - - https://pingarthur.com/api/v1/openapi - - -` - -// llmsTxt follows llmstxt.org — a one-screen pointer aimed at coding agents. -// PingArthur is LLM-first by design: the OpenAPI spec IS the docs, so this -// file's job is to get an agent from the bare domain to the spec in one hop. -const llmsTxt = `# PingArthur - -> LLM-first uptime monitoring. Prove URL ownership with a header echo, then poll for the latest status. The OpenAPI spec is the canonical reference for endpoint shapes, limits, and behavior. - -## API - -- [OpenAPI 3.1 spec (JSON)](https://pingarthur.com/api/v1/openapi.json): full machine-readable contract — endpoints, schemas, limits, timeouts. - -## Background - -- [Homepage](https://pingarthur.com/): one-screen overview. -- [Source on Tangled](https://tangled.org/divepool.social/pingarthur): MIT, self-hostable. -` - -func serveRobotsTxt(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - w.Header().Set("Cache-Control", "public, max-age=3600") - _, _ = io.WriteString(w, robotsTxt) -} - -func serveSitemapXML(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/xml; charset=utf-8") - w.Header().Set("Cache-Control", "public, max-age=3600") - _, _ = io.WriteString(w, sitemapXML) -} - -func serveLLMsTxt(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - w.Header().Set("Cache-Control", "public, max-age=3600") - _, _ = io.WriteString(w, llmsTxt) -} diff --git a/status.go b/status.go deleted file mode 100644 index acf0682..0000000 --- a/status.go +++ /dev/null @@ -1,75 +0,0 @@ -package main - -import ( - "encoding/json" - "log" - "net/http" - "time" - - clientv3 "go.etcd.io/etcd/client/v3" -) - -// statusHandler serves GET /api/v1/status. Bearer token hash is the monitor -// identity; we only check the HMAC signature, not the 1h bind-window expiry. -// Once bound, the token is usable indefinitely — expiry only governs POST -// /api/v1/monitors (see token.go verifyToken vs verifyTokenSignature). -// -// Response shape is the StatusResponse in openapi.json — keep them in sync. -func statusHandler(secret []byte, etcd *clientv3.Client) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - token := extractBearer(r) - if token == "" { - http.Error(w, "missing bearer token", http.StatusUnauthorized) - return - } - if !verifyTokenSignature(secret, token) { - http.Error(w, "invalid bearer token", http.StatusUnauthorized) - return - } - - resp, err := etcd.Get(r.Context(), monitorKey(tokenHash(token))) - if err != nil { - log.Fatalf("status get monitor: %v", err) - } - if resp.Count == 0 { - // Covers both "never bound" and "auto-stopped after 24h of missing - // token echo" — both legitimately surface as 404 per CLAUDE.md. - http.Error(w, "monitor not found", http.StatusNotFound) - return - } - var blob monitorBlob - if err := json.Unmarshal(resp.Kvs[0].Value, &blob); err != nil { - log.Fatalf("unmarshal monitor blob: %v", err) - } - - // Atmosphere: only surface display-safe fields — NEVER the encrypted - // password, nonce, DID (stable but unnecessary), or PDS URL. Handle - // is user-supplied and already public (appears in every post). - type atmoPublic struct { - Handle string `json:"handle"` - LastPostAt time.Time `json:"last_post_at,omitzero"` - LastPostError string `json:"last_post_error,omitempty"` - LastPostErrorAt time.Time `json:"last_post_error_at,omitzero"` - } - var atmo *atmoPublic - if blob.Atmosphere != nil { - atmo = &atmoPublic{ - Handle: blob.Atmosphere.Handle, - LastPostAt: blob.Atmosphere.LastPostAt, - LastPostError: blob.Atmosphere.LastPostError, - LastPostErrorAt: blob.Atmosphere.LastPostErrorAt, - } - } - body, err := json.Marshal(struct { - URL string `json:"url"` - Current observation `json:"current"` - History []observation `json:"history"` - Atmosphere *atmoPublic `json:"atmosphere,omitempty"` - }{blob.URL, blob.Current, blob.History, atmo}) - if err != nil { - log.Fatalf("marshal status response: %v", err) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(body) - } -} diff --git a/status_test.go b/status_test.go deleted file mode 100644 index 872b647..0000000 --- a/status_test.go +++ /dev/null @@ -1,162 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" -) - -// statusReq builds a GET /api/v1/status request with the supplied bearer. -func statusReq(token string) *http.Request { - r := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil) - if token != "" { - r.Header.Set("Authorization", "Bearer "+token) - } - return r -} - -func TestStatus_Success(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - - tok, tokHash := bindOne(t, cli, secret, true) - - // Seed a non-trivial current + history so the response shape is exercised. - blob, _ := readBlob(t, cli, tokHash) - blob.Current = observation{HTTPStatus: 503, HealthStatus: "degraded", Error: "", ObservedAt: time.Now().UTC()} - blob.History = []observation{ - {HTTPStatus: 200, ObservedAt: time.Now().UTC().Add(-time.Hour)}, - } - blobBytes, _ := json.Marshal(blob) - if _, err := cli.Put(context.Background(), monitorKey(tokHash), string(blobBytes)); err != nil { - t.Fatalf("seed blob: %v", err) - } - - h := statusHandler(secret, cli) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, statusReq(tok)) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d (%s); want 200", rec.Code, rec.Body.String()) - } - if ct := rec.Header().Get("Content-Type"); ct != "application/json" { - t.Errorf("Content-Type = %q; want application/json", ct) - } - - var resp struct { - URL string `json:"url"` - Current observation `json:"current"` - History []observation `json:"history"` - } - if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { - t.Fatalf("decode response: %v", err) - } - if resp.URL != "https://www.example.com/" { - t.Errorf("url = %q; want https://www.example.com/", resp.URL) - } - if resp.Current.HTTPStatus != 503 { - t.Errorf("current.http_status = %d; want 503", resp.Current.HTTPStatus) - } - if resp.Current.HealthStatus != "degraded" { - t.Errorf("current.health_status = %q; want degraded", resp.Current.HealthStatus) - } - if len(resp.History) != 1 || resp.History[0].HTTPStatus != 200 { - t.Errorf("history = %v; want 1 entry with http_status=200", resp.History) - } -} - -func TestStatus_NoBearer_401(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - h := statusHandler(secret, cli) - - rec := httptest.NewRecorder() - h.ServeHTTP(rec, statusReq("")) - if rec.Code != http.StatusUnauthorized { - t.Fatalf("status = %d; want 401", rec.Code) - } -} - -func TestStatus_BadToken_401(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - h := statusHandler(secret, cli) - - rec := httptest.NewRecorder() - h.ServeHTTP(rec, statusReq("pa_garbage")) - if rec.Code != http.StatusUnauthorized { - t.Fatalf("status = %d; want 401", rec.Code) - } -} - -func TestStatus_NoMonitor_404(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - h := statusHandler(secret, cli) - - // Valid HMAC, but nothing bound for this hash. - tok := issueTestToken(secret) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, statusReq(tok)) - if rec.Code != http.StatusNotFound { - t.Fatalf("status = %d; want 404", rec.Code) - } -} - -// TestStatus_PostBindWindow_Still200 proves the bind-window expiry doesn't -// apply to /api/v1/status — once bound, the token's hash is the monitor -// identity and stays usable indefinitely. Per CLAUDE.md auto-stop posture, -// the only way to lose access is 24h of missing/mismatched token echo. -func TestStatus_PostBindWindow_Still200(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - - // Token issued 2h ago — well past the 1h bind window. - tok, _ := issueToken(secret, time.Now().Add(-2*time.Hour)) - - // Seed a monitor blob directly; bind would reject the expired token. - blob := monitorBlob{ - URL: "https://www.example.com/", - ETLD1: "example.com", - LastTokenSeenAt: time.Now().UTC(), - CreatedAt: time.Now().UTC().Add(-2 * time.Hour), - Current: observation{HTTPStatus: 200, ObservedAt: time.Now().UTC()}, - History: []observation{}, - } - blobBytes, _ := json.Marshal(blob) - if _, err := cli.Put(context.Background(), monitorKey(tokenHash(tok)), string(blobBytes)); err != nil { - t.Fatalf("seed blob: %v", err) - } - - h := statusHandler(secret, cli) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, statusReq(tok)) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d (%s); want 200 (expired token must still work post-bind)", rec.Code, rec.Body.String()) - } -} - -// TestStatus_HistoryEmptyIsArray guards the openapi contract — `history` is -// required and must be an array, never null. New monitor, no changes yet. -func TestStatus_HistoryEmptyIsArray(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - - tok, _ := bindOne(t, cli, secret, true) - - h := statusHandler(secret, cli) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, statusReq(tok)) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d; want 200", rec.Code) - } - - // Raw body check: must contain `"history":[]`, not `"history":null`. - if body := rec.Body.String(); !strings.Contains(body, `"history":[]`) { - t.Errorf("response body missing %q; got: %s", `"history":[]`, body) - } -} diff --git a/supervision.go b/supervision.go deleted file mode 100644 index 42cdb9e..0000000 --- a/supervision.go +++ /dev/null @@ -1,274 +0,0 @@ -package main - -import ( - "context" - "log" - "net/http" - "strconv" - "sync/atomic" - "time" - - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promauto" - clientv3 "go.etcd.io/etcd/client/v3" -) - -// Metrics. Names match the contract documented in CLAUDE.md (Supervision) and -// PLAN.md (step 8). Registered with the default registry via promauto so -// promhttp.Handler() picks them up without manual wiring. -// -// Semantics in a 3-node deploy: event counters (pa_check_total, -// pa_bind_total, pa_status_change_total, pa_scavenger_recovered_total, -// pa_monitor_autostop_total, pa_checker_scans_total) are *per-node* — the -// cluster total is `sum()` over instances. Gauges sourced from etcd -// (pa_monitors_active, pa_check_queue_depth, pa_check_overdue_seconds) are -// cluster-global and look triplicated; use `max()` in queries. -var ( - metricCheckTotal = promauto.NewCounterVec(prometheus.CounterOpts{ - Name: "pa_check_total", - Help: "HEAD probes performed by the checker, labeled by normalized result category.", - }, []string{"result"}) - - metricCheckDuration = promauto.NewHistogram(prometheus.HistogramOpts{ - Name: "pa_check_duration_seconds", - Help: "Wall-clock time spent in a single HEAD probe.", - // Buckets are sized against our 10s outbound deadline. The default - // buckets top out at 10s too but are weighted toward sub-100ms - // responses that we rarely see from real targets. - Buckets: []float64{.05, .1, .25, .5, 1, 2, 5, 10}, - }) - - metricCheckLastSuccess = promauto.NewGauge(prometheus.GaugeOpts{ - Name: "pa_check_last_success_seconds", - Help: "Unix timestamp of the most recent successful HEAD probe (token matched).", - }) - - metricMonitorsActive = promauto.NewGauge(prometheus.GaugeOpts{ - Name: "pa_monitors_active", - Help: "Currently bound monitors (mirror of stats/monitor_count in etcd).", - }) - - metricStatusChangeTotal = promauto.NewCounter(prometheus.CounterOpts{ - Name: "pa_status_change_total", - Help: "Times the observation tuple changed, pushing an entry into history.", - }) - - metricEtcdUp = promauto.NewGauge(prometheus.GaugeOpts{ - Name: "pa_etcd_up", - Help: "1 if the most recent local-etcd status probe succeeded, 0 otherwise.", - }) - - // pa_bind_total labels: result ∈ {ok, bad_request, unauthorized, - // verify_failed, oversize, cap, lease_expired}. Captures the full - // outcome surface of POST /api/v1/monitors so operators can SLO - // bind success rate without reading Caddy logs. - metricBindTotal = promauto.NewCounterVec(prometheus.CounterOpts{ - Name: "pa_bind_total", - Help: "POST /api/v1/monitors attempts, labeled by outcome.", - }, []string{"result"}) - - // Monitors auto-stopped by the 24h no-echo teardown path. A sudden - // spike usually means users' targets changed their echo behaviour. - metricAutoStopTotal = promauto.NewCounter(prometheus.CounterOpts{ - Name: "pa_monitor_autostop_total", - Help: "Monitors auto-stopped after 24h of missing or mismatched token echo.", - }) - - // pa_scavenger_recovered_total bumps per re-inserted due/ entry. - // Nonzero rate means scavenger is actively recovering from claim - // lease expirations (crashed mid-check workers). - metricScavengerRecovered = promauto.NewCounter(prometheus.CounterOpts{ - Name: "pa_scavenger_recovered_total", - Help: "due/ entries re-inserted by the scavenger for orphaned monitors.", - }) - - // Heartbeat counter — ticks every checkScanInterval regardless of - // whether any due entries were found. Distinguishes "checker alive - // but idle" from "checker stalled" during quiet periods when - // pa_check_total isn't incrementing. - metricCheckerScansTotal = promauto.NewCounter(prometheus.CounterOpts{ - Name: "pa_checker_scans_total", - Help: "Checker scan ticks (every checkScanInterval).", - }) - - // Total due/ entries in the cluster right now. Sampled by the - // watchdog tick (every etcdProbeInterval). Steady-state equals the - // active monitor count; a persistent gap is a backlog signal. - metricCheckQueueDepth = promauto.NewGauge(prometheus.GaugeOpts{ - Name: "pa_check_queue_depth", - Help: "Total due/* keys right now (should equal pa_monitors_active at steady state).", - }) - - // Age of the oldest past-deadline due/ entry in seconds. 0 means no - // entry is overdue. A minute+ is the "checker silently stalled" - // signal — pa_check_last_success_seconds can miss this during - // low-traffic periods. - metricCheckOverdueSeconds = promauto.NewGauge(prometheus.GaugeOpts{ - Name: "pa_check_overdue_seconds", - Help: "Seconds-past-deadline of the oldest overdue due/ entry; 0 if none.", - }) - - // Atmosphere post outcomes on observation-tuple changes for monitors - // that have a binding. Labels: ok | decrypt_failed | auth_failed | - // post_failed | resolve_failed. Per-node counter. - metricAtmospherePostTotal = promauto.NewCounterVec(prometheus.CounterOpts{ - Name: "pa_atmosphere_post_total", - Help: "Atmosphere posts attempted on observation-tuple changes, labeled by outcome.", - }, []string{"result"}) - - // Timestamp of the most recent successful atmosphere post. Gap from - // `time()` is the "have any posts been landing lately" signal. - metricAtmospherePostLastSuccess = promauto.NewGauge(prometheus.GaugeOpts{ - Name: "pa_atmosphere_post_last_success_seconds", - Help: "Unix timestamp of the most recent successful atmosphere post.", - }) -) - -const ( - etcdProbeInterval = 5 * time.Second - etcdProbeTimeout = 3 * time.Second - // After this many consecutive failed probes (≈ 25 s with the 5 s interval), - // log.Fatalf so systemd restarts us and DNS-RR fails traffic over to the - // other 2 nodes. Shorter ⇒ flappy; longer ⇒ clients stay stuck routed here. - etcdFailThreshold = 5 -) - -// etcdReachable is written by runWatchdog and read by etcdHealthHandler. An -// atomic bool is plenty — the handler just needs the latest probe outcome -// and doesn't care about serializability across metric updates. -var etcdReachable atomic.Bool - -// etcdHealthHandler serves the public, unauthenticated /status (polled by Oh -// Dear + DNS-RR clients). 200 on the most recent watchdog probe success, 503 -// otherwise. Separate concept from /api/v1/status (monitor state for a token). -func etcdHealthHandler() http.HandlerFunc { - return func(w http.ResponseWriter, _ *http.Request) { - if !etcdReachable.Load() { - http.Error(w, "etcd unreachable", http.StatusServiceUnavailable) - return - } - _, _ = w.Write([]byte("ok\n")) - } -} - -// runWatchdog probes the local etcd member on a ticker, updates the reachable -// flag + pa_etcd_up, and refreshes pa_monitors_active off the same probe. -// After etcdFailThreshold consecutive failures it log.Fatalf's — systemd -// restarts the process, clients retry the next A record, quorum is preserved -// by the other 2 nodes. -func runWatchdog(ctx context.Context, etcd *clientv3.Client, endpoint string) { - var consecutive int - ticker := time.NewTicker(etcdProbeInterval) - defer ticker.Stop() - - probe := func() { - pCtx, cancel := context.WithTimeout(ctx, etcdProbeTimeout) - defer cancel() - if _, err := etcd.Status(pCtx, endpoint); err != nil { - consecutive++ - etcdReachable.Store(false) - metricEtcdUp.Set(0) - if consecutive >= etcdFailThreshold { - log.Fatalf("etcd unreachable for %d consecutive probes: %v", consecutive, err) - } - return - } - consecutive = 0 - etcdReachable.Store(true) - metricEtcdUp.Set(1) - refreshMonitorCount(pCtx, etcd) - refreshQueueMetrics(pCtx, etcd) - } - - // Probe immediately so /status doesn't falsely report 503 for the full - // first interval on startup. - probe() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - probe() - } - } -} - -// refreshMonitorCount mirrors stats/monitor_count into pa_monitors_active. -// Called off the watchdog probe so the gauge stays within ~etcdProbeInterval -// of the truth without polling etcd on its own cadence. -func refreshMonitorCount(ctx context.Context, etcd *clientv3.Client) { - resp, err := etcd.Get(ctx, keyStatsCount) - if err != nil { - if ctx.Err() != nil { - return - } - log.Fatalf("watchdog get monitor_count: %v", err) - } - if resp.Count == 0 { - metricMonitorsActive.Set(0) - return - } - n, err := strconv.ParseInt(string(resp.Kvs[0].Value), 10, 64) - if err != nil { - log.Fatalf("watchdog parse monitor_count %q: %v", resp.Kvs[0].Value, err) - } - metricMonitorsActive.Set(float64(n)) -} - -// refreshQueueMetrics samples two cluster-global state signals in one -// watchdog tick: the total count of due/ entries (queue depth) and the -// age of the oldest overdue entry. Both are cheap point reads against -// the local etcd member — the keyspace is ordered, so the single-key -// range scan for the oldest is O(log n) + 1. -func refreshQueueMetrics(ctx context.Context, etcd *clientv3.Client) { - // Total count — cluster-wide, independent of pagination. - countResp, err := etcd.Get(ctx, keyPrefixDue, clientv3.WithPrefix(), clientv3.WithCountOnly()) - if err != nil { - if ctx.Err() != nil { - return - } - log.Fatalf("watchdog get due count: %v", err) - } - metricCheckQueueDepth.Set(float64(countResp.Count)) - - // Oldest — one-key range scan, ascending. If it's ≤ now, compute - // overdue age; otherwise queue is ahead of wall clock (typical). - oldestResp, err := etcd.Get(ctx, keyPrefixDue, - clientv3.WithPrefix(), - clientv3.WithLimit(1), - clientv3.WithSort(clientv3.SortByKey, clientv3.SortAscend)) - if err != nil { - if ctx.Err() != nil { - return - } - log.Fatalf("watchdog get oldest due: %v", err) - } - if len(oldestResp.Kvs) == 0 { - metricCheckOverdueSeconds.Set(0) - return - } - _, scheduled, ok := parseDueKey(string(oldestResp.Kvs[0].Key)) - if !ok { - log.Fatalf("watchdog malformed due key: %q", oldestResp.Kvs[0].Key) - } - overdue := time.Since(scheduled).Seconds() - if overdue < 0 { - overdue = 0 - } - metricCheckOverdueSeconds.Set(overdue) -} - -// checkResultLabel is the {result=...} value for pa_check_total. "ok" on a -// matched probe; otherwise the normalized error category from the observation. -func checkResultLabel(matched bool, obs observation) string { - if matched { - return "ok" - } - // An unmatched observation always carries an error category in practice, - // but if that invariant ever breaks we don't want to silently emit a label - // with an empty value (cardinality surprise in dashboards). - if obs.Error == "" { - return errCatOther - } - return obs.Error -} diff --git a/supervision_test.go b/supervision_test.go deleted file mode 100644 index fd6e1fd..0000000 --- a/supervision_test.go +++ /dev/null @@ -1,160 +0,0 @@ -package main - -import ( - "context" - "net/http" - "net/http/httptest" - "sync/atomic" - "testing" - "time" - - "github.com/prometheus/client_golang/prometheus/testutil" -) - -func TestEtcdHealthHandler_TogglesWithFlag(t *testing.T) { - h := etcdHealthHandler() - - // Flag defaults to false unless watchdog has seeded it. Ensure we start - // in the explicit "down" state regardless of other tests running before us. - etcdReachable.Store(false) - - rec := httptest.NewRecorder() - h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/status", nil)) - if rec.Code != http.StatusServiceUnavailable { - t.Errorf("down: status = %d; want 503", rec.Code) - } - - etcdReachable.Store(true) - rec = httptest.NewRecorder() - h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/status", nil)) - if rec.Code != http.StatusOK { - t.Errorf("up: status = %d; want 200", rec.Code) - } -} - -// TestWatchdog_HappyPath confirms the watchdog flips the flag, sets pa_etcd_up, -// and mirrors stats/monitor_count into pa_monitors_active. Uses embedded etcd, -// so the probe round-trips for real. -func TestWatchdog_HappyPath(t *testing.T) { - cli := startEmbeddedEtcd(t) - - // Seed the counter so pa_monitors_active has something non-zero to mirror. - if _, err := cli.Put(context.Background(), keyStatsCount, "7"); err != nil { - t.Fatalf("seed stats: %v", err) - } - - // Reset flag + gauges (package globals leak across tests otherwise). - etcdReachable.Store(false) - metricEtcdUp.Set(0) - metricMonitorsActive.Set(0) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // embedded etcd's endpoint is visible on the client — grab the first. - endpoints := cli.Endpoints() - if len(endpoints) == 0 { - t.Fatal("no endpoints on embedded etcd client") - } - - go runWatchdog(ctx, cli, endpoints[0]) - - // runWatchdog's initial probe is synchronous-on-goroutine-start; wait a - // short beat, then assert. - waitUntil(t, time.Second, func() bool { return etcdReachable.Load() }) - - if !etcdReachable.Load() { - t.Fatal("etcdReachable still false after watchdog startup") - } - if got := testutil.ToFloat64(metricEtcdUp); got != 1 { - t.Errorf("pa_etcd_up = %v; want 1", got) - } - if got := testutil.ToFloat64(metricMonitorsActive); got != 7 { - t.Errorf("pa_monitors_active = %v; want 7", got) - } -} - -// TestBindTotalLabels: every result path of bindMonitorHandler bumps -// pa_bind_total with a non-empty label. The handler has 6 distinct -// outcomes — any future branch without the counter bump would regress -// operator visibility without any test failure, so this assertion -// locks the contract. -func TestBindTotalLabels(t *testing.T) { - cli := startEmbeddedEtcd(t) - secret := mustSecret(t) - - // Reset to a known baseline so the per-test deltas are easy to read. - metricBindTotal.Reset() - - var calls atomic.Int64 - h := bindMonitorHandler(context.Background(), secret, cli, fakeVerifier(true, &calls), 100, nil, nil) - - // Each of these hits a different branch in the handler. - tt := []struct { - name string - req *http.Request - wantLabel string - wantStatus int - }{ - {"no bearer", bindReq("", "https://ok.example.com/"), "unauthorized", http.StatusUnauthorized}, - {"bad bearer", bindReq("pa_garbage", "https://ok.example.com/"), "unauthorized", http.StatusUnauthorized}, - {"bad url", bindReq(issueTestToken(secret), "http://wrong-scheme.example.com/"), "bad_request", http.StatusBadRequest}, - } - for _, tc := range tt { - rec := httptest.NewRecorder() - h.ServeHTTP(rec, tc.req) - if rec.Code != tc.wantStatus { - t.Fatalf("%s: status = %d; want %d", tc.name, rec.Code, tc.wantStatus) - } - if got := testutil.ToFloat64(metricBindTotal.WithLabelValues(tc.wantLabel)); got < 1 { - t.Errorf("%s: pa_bind_total{result=%q} = %v; want ≥ 1", tc.name, tc.wantLabel, got) - } - } - - // Success path: binds a valid monitor; label "ok" must be ≥ 1. - rec := httptest.NewRecorder() - h.ServeHTTP(rec, bindReq(issueTestToken(secret), "https://ok.example.com/")) - if rec.Code != http.StatusCreated { - t.Fatalf("success: status = %d; want 201", rec.Code) - } - if got := testutil.ToFloat64(metricBindTotal.WithLabelValues("ok")); got < 1 { - t.Errorf("success: pa_bind_total{result=\"ok\"} = %v; want ≥ 1", got) - } -} - -// TestCheckResultLabel covers the label-mapping invariants the counter relies -// on: matched ⇒ "ok", unmatched ⇒ obs.Error, empty error ⇒ "other" (no empty -// label value leaks into the metric, which would surprise dashboards). -func TestCheckResultLabel(t *testing.T) { - cases := []struct { - name string - matched bool - errCat string - want string - }{ - {"matched", true, "", "ok"}, - {"matched overrides error", true, "dns", "ok"}, - {"unmatched with category", false, "token_mismatch", "token_mismatch"}, - {"unmatched empty falls back", false, "", errCatOther}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got := checkResultLabel(tc.matched, observation{Error: tc.errCat}) - if got != tc.want { - t.Errorf("checkResultLabel(matched=%v, err=%q) = %q; want %q", tc.matched, tc.errCat, got, tc.want) - } - }) - } -} - -// waitUntil polls until f() returns true or the timeout elapses. 10ms ticks. -func waitUntil(t *testing.T, timeout time.Duration, f func() bool) { - t.Helper() - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - if f() { - return - } - time.Sleep(10 * time.Millisecond) - } -} diff --git a/token.go b/token.go deleted file mode 100644 index 688207b..0000000 --- a/token.go +++ /dev/null @@ -1,107 +0,0 @@ -package main - -import ( - "crypto/hmac" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/binary" - "encoding/hex" - "io" - "log" - "strings" - "time" -) - -// tokenHash is the keyspace identity for a bound monitor — SHA-256 of the -// raw token bytes, hex. We never store the token itself; only its hash, so -// a leak of etcd contents doesn't leak any bearer credentials. -func tokenHash(token string) string { - sum := sha256.Sum256([]byte(token)) - return hex.EncodeToString(sum[:]) -} - -const ( - tokenPrefix = "pa_" - // 8 random bytes (64-bit unguessability — HMAC is the actual authority) - // + 4 timestamp bytes (uint32 unix seconds, valid until 2106) - // + 16 HMAC-SHA256 truncation bytes (RFC 4868 / NIST: HMAC-SHA-256-128 - // is the standard 128-bit MAC tag — collision and forgery resistance - // stay at the symmetric-crypto floor while halving token length). - // Total raw = 28 B → 38 base64url chars + 3-char prefix = 41 chars. - tokenRandomBytes = 8 - tokenTimestampBytes = 4 - tokenMACBytes = 16 - tokenPayloadBytes = tokenRandomBytes + tokenTimestampBytes - tokenRawBytes = tokenPayloadBytes + tokenMACBytes - tokenValidity = time.Hour -) - -// issueToken produces a stateless bearer token of the form -// pa_ -// It returns the token string and its bind-window expiry (now + tokenValidity). -// After binding, the token stays usable indefinitely as a bearer for /api/v1/status; -// expiresAt only governs how long a fresh, unbound token can be accepted by /api/v1/monitors. -func issueToken(secret []byte, now time.Time) (string, time.Time) { - payload := make([]byte, tokenPayloadBytes) - if _, err := io.ReadFull(rand.Reader, payload[:tokenRandomBytes]); err != nil { - log.Fatalf("rand.Reader: %v", err) - } - binary.BigEndian.PutUint32(payload[tokenRandomBytes:], uint32(now.Unix())) - - mac := hmac.New(sha256.New, secret) - mac.Write(payload) - - raw := make([]byte, tokenRawBytes) - copy(raw, payload) - copy(raw[tokenPayloadBytes:], mac.Sum(nil)[:tokenMACBytes]) - - return tokenPrefix + base64.RawURLEncoding.EncodeToString(raw), now.Add(tokenValidity) -} - -// decodeAndAuthenticate validates the token's prefix, base64 framing, and -// HMAC. Returns the issued-at instant and true iff signature is intact. -// No expiry check — callers that care about the bind window add it on top. -func decodeAndAuthenticate(secret []byte, token string) (time.Time, bool) { - rest, ok := strings.CutPrefix(token, tokenPrefix) - if !ok { - return time.Time{}, false - } - raw, err := base64.RawURLEncoding.DecodeString(rest) - if err != nil || len(raw) != tokenRawBytes { - return time.Time{}, false - } - - payload := raw[:tokenPayloadBytes] - gotMAC := raw[tokenPayloadBytes:] - - mac := hmac.New(sha256.New, secret) - mac.Write(payload) - if !hmac.Equal(mac.Sum(nil)[:tokenMACBytes], gotMAC) { - return time.Time{}, false - } - issuedAt := time.Unix(int64(binary.BigEndian.Uint32(payload[tokenRandomBytes:])), 0) - return issuedAt, true -} - -// verifyTokenSignature is the checker's cheap path: HMAC is valid, expiry -// is irrelevant. A token's bind-window expiry only governs whether it can -// mint a new monitor; once bound, the token's hash is the monitor identity -// and stays usable indefinitely as the X-PingArthur-Token echo. -func verifyTokenSignature(secret []byte, token string) bool { - _, ok := decodeAndAuthenticate(secret, token) - return ok -} - -// verifyToken adds the bind-window expiry on top of verifyTokenSignature. -// Bind handler uses this; checker uses verifyTokenSignature. -func verifyToken(secret []byte, token string, now time.Time) (time.Time, bool) { - issuedAt, ok := decodeAndAuthenticate(secret, token) - if !ok { - return time.Time{}, false - } - if now.Before(issuedAt) || now.Sub(issuedAt) > tokenValidity { - return time.Time{}, false - } - return issuedAt, true -} diff --git a/token_test.go b/token_test.go deleted file mode 100644 index 7c713da..0000000 --- a/token_test.go +++ /dev/null @@ -1,139 +0,0 @@ -package main - -import ( - "crypto/rand" - "encoding/base64" - "strings" - "testing" - "time" -) - -func mustSecret(t *testing.T) []byte { - t.Helper() - b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { - t.Fatalf("rand.Read: %v", err) - } - return b -} - -func TestIssueAndVerify_Roundtrip(t *testing.T) { - secret := mustSecret(t) - now := time.Now() - - token, expiresAt := issueToken(secret, now) - - if !strings.HasPrefix(token, tokenPrefix) { - t.Fatalf("token %q missing prefix %q", token, tokenPrefix) - } - if want := now.Add(tokenValidity); !expiresAt.Equal(want) { - t.Fatalf("expiresAt = %v; want %v", expiresAt, want) - } - - issuedAt, ok := verifyToken(secret, token, now) - if !ok { - t.Fatal("verifyToken returned false for a fresh token") - } - if want := now.Truncate(time.Second); !issuedAt.Equal(want) { - t.Fatalf("issuedAt = %v; want %v (second-truncated)", issuedAt, want) - } -} - -func TestIssueToken_Shape(t *testing.T) { - secret := mustSecret(t) - token, _ := issueToken(secret, time.Now()) - - wantLen := len(tokenPrefix) + base64.RawURLEncoding.EncodedLen(tokenRawBytes) - if len(token) != wantLen { - t.Fatalf("len(token) = %d; want %d", len(token), wantLen) - } - - body, err := base64.RawURLEncoding.DecodeString(token[len(tokenPrefix):]) - if err != nil { - t.Fatalf("token body is not valid base64url: %v", err) - } - if len(body) != tokenRawBytes { - t.Fatalf("decoded body = %d bytes; want %d", len(body), tokenRawBytes) - } -} - -func TestIssueToken_Uniqueness(t *testing.T) { - secret := mustSecret(t) - now := time.Now() - - seen := make(map[string]struct{}, 1000) - for i := range 1000 { - tok, _ := issueToken(secret, now) - if _, dup := seen[tok]; dup { - t.Fatalf("duplicate token at iteration %d: %s", i, tok) - } - seen[tok] = struct{}{} - } -} - -func TestVerify_Expired(t *testing.T) { - secret := mustSecret(t) - issuedAt := time.Now() - token, _ := issueToken(secret, issuedAt) - - tooLate := issuedAt.Add(tokenValidity + time.Second) - if _, ok := verifyToken(secret, token, tooLate); ok { - t.Fatal("verifyToken returned true for an expired token") - } -} - -func TestVerify_FutureIssuance(t *testing.T) { - secret := mustSecret(t) - future := time.Now().Add(time.Hour) - token, _ := issueToken(secret, future) - - if _, ok := verifyToken(secret, token, time.Now()); ok { - t.Fatal("verifyToken returned true for a token issued in the future") - } -} - -func TestVerify_TamperedMAC(t *testing.T) { - secret := mustSecret(t) - token, _ := issueToken(secret, time.Now()) - - // flip a character well inside the base64 body — the very last char - // can encode padding bits (RawURLEncoding has no `=` but still has - // up to 4 unused bits at the tail when the byte length isn't a - // multiple of 3), and flipping a padding bit decodes to the same - // bytes. Pick a position that's guaranteed to land on a real bit. - bts := []byte(token) - mid := len(tokenPrefix) + 4 // a few chars into the body, well clear of any tail padding - if bts[mid] == 'A' { - bts[mid] = 'B' - } else { - bts[mid] = 'A' - } - tampered := string(bts) - - if _, ok := verifyToken(secret, tampered, time.Now()); ok { - t.Fatal("verifyToken returned true for tampered token") - } -} - -func TestVerify_WrongSecret(t *testing.T) { - token, _ := issueToken(mustSecret(t), time.Now()) - if _, ok := verifyToken(mustSecret(t), token, time.Now()); ok { - t.Fatal("verifyToken returned true with a different secret") - } -} - -func TestVerify_Malformed(t *testing.T) { - secret := mustSecret(t) - cases := []string{ - "", - "pa_", - "pa_not-base64!", // invalid base64 character - "pa_AAAA", // valid base64, but decodes to too few bytes - "nopfx_" + strings.Repeat("A", base64.RawURLEncoding.EncodedLen(tokenRawBytes)), // correct length, wrong prefix - } - for _, tc := range cases { - if _, ok := verifyToken(secret, tc, time.Now()); ok { - t.Fatalf("verifyToken returned true for malformed token %q", tc) - } - } -} diff --git a/verify.go b/verify.go deleted file mode 100644 index 56b308b..0000000 --- a/verify.go +++ /dev/null @@ -1,221 +0,0 @@ -package main - -import ( - "context" - "crypto/subtle" - "crypto/tls" - "crypto/x509" - "errors" - "fmt" - "net" - "net/http" - "net/url" - "strings" - "syscall" - "time" - - "golang.org/x/net/publicsuffix" -) - -type observation struct { - HTTPStatus int `json:"http_status"` - HealthStatus string `json:"health_status"` - Error string `json:"error"` - ObservedAt time.Time `json:"observed_at"` -} - -// obsSig is the signature of the post-determining part of an observation — the -// (http status, health status, error) tuple, exactly what observationChanged -// compares and what formatPost renders. The atmosphere binding records the sig -// of the last SUCCESSFULLY posted observation; the checker re-posts whenever the -// current sig differs from it, so a post that failed (transient PDS/DNS error) -// is retried on the next scheduled check until it lands — convergent, rather -// than dropped until the next status change. -func obsSig(o observation) string { - return fmt.Sprintf("%d|%s|%s", o.HTTPStatus, o.HealthStatus, o.Error) -} - -// Normalized error categories. "" means the observation succeeded (token -// matched and HEAD returned a status). Stored verbatim in the monitor blob -// and served via /api/v1/status — keep the set small and stable. -const ( - errCatTokenMismatch = "token_mismatch" - errCatBlocked = "blocked" - errCatDNS = "dns" - errCatTLS = "tls" - errCatTimeout = "timeout" - errCatRefused = "refused" - errCatOther = "other" - - // 280-rune cap. Bluesky `app.bsky.feed.post.text` is bounded by - // maxGraphemes:300 / maxLength:3000 (bytes); runes ≥ graphemes for any - // string, so a 280-rune cap is a safe conservative proxy that keeps the - // recorded value drop-in postable to the user's atmosphere account - // without further truncation. Worst-case bytes: 280 × 4 = 1120, far - // under etcd's 1.5 MiB value limit. - healthStatusMax = 280 - - // userAgent identifies us to targets. Includes the project URL so admins - // who want to opt out can find us. See openapi.json for behavioral spec. - userAgent = "PingArthur/1.0 (+https://pingarthur.com)" -) - -// prepareURL validates the URL is HTTPS on a real registrable domain and -// extracts the eTLD+1 for the per-domain cap index. Bare IPs are rejected -// up front; they would bypass both publicsuffix and (for private ranges) -// the SSRF dialer's intent. -func prepareURL(raw string) (normalized, etld1 string, err error) { - u, err := url.Parse(raw) - if err != nil { - return "", "", fmt.Errorf("parse: %w", err) - } - if u.Scheme != "https" { - return "", "", fmt.Errorf("scheme %q not allowed; https only", u.Scheme) - } - host := u.Hostname() - if host == "" { - return "", "", errors.New("empty host") - } - if net.ParseIP(host) != nil { - if !insecureDevMode() { - return "", "", errors.New("bare IPs not allowed") - } - // dev mode: bare IP is permitted; publicsuffix doesn't apply, so - // host:port stands in as the per-domain cap key. All loopback - // targets on different ports count as distinct "domains" — fine - // for local testing where the cap isn't load-bearing. - return u.String(), u.Host, nil - } - e, err := publicsuffix.EffectiveTLDPlusOne(host) - if err != nil { - return "", "", fmt.Errorf("eTLD+1: %w", err) - } - return u.String(), e, nil -} - -// headWith performs the HEAD probe shared by verifyURL (bind path) and -// checkURL (checker path), then defers the token-validation step to the -// caller via the validate callback. The callback returns (matched, errCat): -// errCat is ignored when matched is true. -// -// Network/TLS/DNS issues land in observation.Error as a normalized category -// before validate is even called — never as a Go error. -func headWith(ctx context.Context, client *http.Client, rawURL string, validate func(http.Header) (bool, string)) (bool, observation) { - obs := observation{ObservedAt: time.Now().UTC()} - - req, err := http.NewRequestWithContext(ctx, http.MethodHead, rawURL, nil) - if err != nil { - obs.Error = errCatOther - return false, obs - } - req.Header.Set("User-Agent", userAgent) - - resp, err := client.Do(req) - if err != nil { - obs.Error = classifyNetErr(err) - return false, obs - } - defer resp.Body.Close() - - obs.HTTPStatus = resp.StatusCode - if hs := resp.Header.Get("X-Health-Status"); hs != "" { - // First strip any invalid UTF-8 — HTTP headers are bytes, and a - // malformed sequence would otherwise marshal to JSON with U+FFFD - // replacement. Then rune-cap so the recorded value is always - // drop-in postable to a Bluesky post (see healthStatusMax). - hs = strings.ToValidUTF8(hs, "") - if r := []rune(hs); len(r) > healthStatusMax { - hs = string(r[:healthStatusMax]) - } - obs.HealthStatus = hs - } - - matched, errCat := validate(resp.Header) - if !matched { - obs.Error = errCat - return false, obs - } - return true, obs -} - -// verifyURL is the bind-path verifier: target must echo the exact bearer -// token in X-PingArthur-Token (constant-time compare). The echoed value is -// held in a short-lived []byte zeroed after compare; we never log it. -func verifyURL(ctx context.Context, client *http.Client, rawURL, token string) (matched bool, obs observation) { - return headWith(ctx, client, rawURL, func(h http.Header) (bool, string) { - echoed := []byte(h.Get("X-PingArthur-Token")) - ok := subtle.ConstantTimeCompare(echoed, []byte(token)) == 1 - for i := range echoed { - echoed[i] = 0 - } - if !ok { - return false, errCatTokenMismatch - } - return true, "" - }) -} - -// checkURL is the checker-path verifier: target must echo *some* HMAC-valid -// token whose hash equals expectedTokHash. The checker has only the hash -// (extracted from the etcd key) and the shared HMAC secret — it never sees -// or stores the bearer. Self-validating tokens make this work: anyone could -// claim "the token is X" but only an X with a valid HMAC and matching hash -// proves the target is echoing what it bound with. -func checkURL(ctx context.Context, client *http.Client, secret []byte, rawURL, expectedTokHash string) (matched bool, obs observation) { - return headWith(ctx, client, rawURL, func(h http.Header) (bool, string) { - echoed := h.Get("X-PingArthur-Token") - if echoed == "" || !verifyTokenSignature(secret, echoed) { - return false, errCatTokenMismatch - } - if subtle.ConstantTimeCompare([]byte(tokenHash(echoed)), []byte(expectedTokHash)) != 1 { - return false, errCatTokenMismatch - } - return true, "" - }) -} - -// classifyNetErr maps transport-layer errors to the normalized category set. -// Typed checks first (errors.Is/As against stdlib types); a small substring -// fallback covers the long tail of TLS/protocol errors that don't have a -// stable concrete type. -func classifyNetErr(err error) string { - if errors.Is(err, errSSRFBlocked) { - return errCatBlocked - } - if errors.Is(err, context.DeadlineExceeded) { - return errCatTimeout - } - if errors.Is(err, syscall.ECONNREFUSED) { - return errCatRefused - } - var dnsErr *net.DNSError - if errors.As(err, &dnsErr) { - return errCatDNS - } - var certVerifyErr *tls.CertificateVerificationError - if errors.As(err, &certVerifyErr) { - return errCatTLS - } - var unkAuth x509.UnknownAuthorityError - if errors.As(err, &unkAuth) { - return errCatTLS - } - var hostnameErr x509.HostnameError - if errors.As(err, &hostnameErr) { - return errCatTLS - } - var certInvalid x509.CertificateInvalidError - if errors.As(err, &certInvalid) { - return errCatTLS - } - var recordErr tls.RecordHeaderError - if errors.As(err, &recordErr) { - return errCatTLS - } - // Long-tail fallback for TLS protocol errors without a stable concrete type. - msg := err.Error() - if strings.Contains(msg, "tls:") || strings.Contains(msg, "x509:") { - return errCatTLS - } - return errCatOther -} diff --git a/verify_test.go b/verify_test.go deleted file mode 100644 index a9119ca..0000000 --- a/verify_test.go +++ /dev/null @@ -1,264 +0,0 @@ -package main - -import ( - "context" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - "unicode/utf8" -) - -func TestPrepareURL(t *testing.T) { - cases := []struct { - name string - in string - wantErr bool - etld1 string - }{ - {"plain https", "https://example.com/", false, "example.com"}, - {"subdomain", "https://www.example.com/path", false, "example.com"}, - {"github.io is a public suffix", "https://alice.github.io/", false, "alice.github.io"}, - {"uk second level", "https://foo.co.uk/", false, "foo.co.uk"}, - {"http rejected", "http://example.com/", true, ""}, - {"ftp rejected", "ftp://example.com/", true, ""}, - {"bare ip rejected", "https://127.0.0.1/", true, ""}, - {"bare ipv6 rejected", "https://[::1]/", true, ""}, - {"empty host rejected", "https:///path", true, ""}, - {"garbage rejected", "::::not-a-url", true, ""}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - _, got, err := prepareURL(c.in) - if c.wantErr { - if err == nil { - t.Fatalf("prepareURL(%q) = nil err; want error", c.in) - } - return - } - if err != nil { - t.Fatalf("prepareURL(%q): %v", c.in, err) - } - if got != c.etld1 { - t.Fatalf("prepareURL(%q) etld1 = %q; want %q", c.in, got, c.etld1) - } - }) - } -} - -// testClient mirrors newSafeHTTPClient but trusts srv's self-signed cert and -// bypasses the SSRF dialer (httptest binds to 127.0.0.1 which is blocked). -// The SSRF path is covered by step 3 tests. -func testClient(srv *httptest.Server) *http.Client { - return &http.Client{ - Timeout: 5 * time.Second, - CheckRedirect: func(*http.Request, []*http.Request) error { - return http.ErrUseLastResponse - }, - Transport: srv.Client().Transport, - } -} - -func TestVerifyURL_TokenMatches(t *testing.T) { - const token = "pa_test_token" - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodHead { - t.Errorf("server got %s; want HEAD", r.Method) - } - w.Header().Set("X-PingArthur-Token", token) - w.Header().Set("X-Health-Status", "healthy") - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - - matched, obs := verifyURL(context.Background(), testClient(srv), srv.URL, token) - if !matched { - t.Fatalf("matched = false; want true (obs=%+v)", obs) - } - if obs.HTTPStatus != 200 { - t.Errorf("HTTPStatus = %d; want 200", obs.HTTPStatus) - } - if obs.HealthStatus != "healthy" { - t.Errorf("HealthStatus = %q; want %q", obs.HealthStatus, "healthy") - } - if obs.Error != "" { - t.Errorf("Error = %q; want empty", obs.Error) - } - if obs.ObservedAt.IsZero() { - t.Error("ObservedAt is zero") - } -} - -func TestVerifyURL_TokenMissing(t *testing.T) { - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - - matched, obs := verifyURL(context.Background(), testClient(srv), srv.URL, "pa_expected") - if matched { - t.Fatal("matched = true; want false (no header)") - } - if obs.Error != errCatTokenMismatch { - t.Errorf("Error = %q; want %q", obs.Error, errCatTokenMismatch) - } - if obs.HTTPStatus != 200 { - t.Errorf("HTTPStatus = %d; want 200 (we still record the status)", obs.HTTPStatus) - } -} - -func TestVerifyURL_TokenMismatch(t *testing.T) { - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-PingArthur-Token", "pa_wrong") - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - - matched, obs := verifyURL(context.Background(), testClient(srv), srv.URL, "pa_expected") - if matched { - t.Fatal("matched = true; want false") - } - if obs.Error != errCatTokenMismatch { - t.Errorf("Error = %q; want %q", obs.Error, errCatTokenMismatch) - } -} - -func TestVerifyURL_HealthStatusTruncated(t *testing.T) { - const token = "pa_t" - long := strings.Repeat("x", healthStatusMax+50) - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-PingArthur-Token", token) - w.Header().Set("X-Health-Status", long) - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - - _, obs := verifyURL(context.Background(), testClient(srv), srv.URL, token) - if got := len([]rune(obs.HealthStatus)); got != healthStatusMax { - t.Fatalf("HealthStatus rune-length = %d; want %d", got, healthStatusMax) - } -} - -// TestVerifyURL_HealthStatusUTF8Safe: rune-cap means a multi-byte string -// is truncated cleanly at rune boundaries, never mid-rune. Confirms the -// cap is rune-based (not byte-based) and that the result stays valid UTF-8. -func TestVerifyURL_HealthStatusUTF8Safe(t *testing.T) { - const token = "pa_t" - // Each € is 3 bytes. healthStatusMax+10 runes → cap should leave - // exactly healthStatusMax runes = healthStatusMax*3 bytes. - long := strings.Repeat("€", healthStatusMax+10) - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-PingArthur-Token", token) - w.Header().Set("X-Health-Status", long) - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - - _, obs := verifyURL(context.Background(), testClient(srv), srv.URL, token) - - if got := len([]rune(obs.HealthStatus)); got != healthStatusMax { - t.Errorf("HealthStatus rune-length = %d; want %d", got, healthStatusMax) - } - if !utf8.ValidString(obs.HealthStatus) { - t.Errorf("HealthStatus is not valid UTF-8: %q", obs.HealthStatus) - } - if got := len(obs.HealthStatus); got != healthStatusMax*3 { - t.Errorf("HealthStatus byte-length = %d; want %d (%d × 3-byte runes)", got, healthStatusMax*3, healthStatusMax) - } -} - -func TestVerifyURL_NonOKStatusStillMatchesWithEcho(t *testing.T) { - const token = "pa_t" - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-PingArthur-Token", token) - w.WriteHeader(http.StatusServiceUnavailable) - })) - defer srv.Close() - - matched, obs := verifyURL(context.Background(), testClient(srv), srv.URL, token) - if !matched { - t.Fatal("matched = false; want true (echo correct even though 503)") - } - if obs.HTTPStatus != 503 { - t.Errorf("HTTPStatus = %d; want 503", obs.HTTPStatus) - } - if obs.Error != "" { - t.Errorf("Error = %q; want empty (token echoed — ownership ok, URL just unhealthy)", obs.Error) - } -} - -func TestVerifyURL_ConnectionRefused(t *testing.T) { - // Spin up then immediately close — we know the port is now free. - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) - url := srv.URL - srv.Close() - - matched, obs := verifyURL(context.Background(), testClient(srv), url, "pa_t") - if matched { - t.Fatal("matched = true; want false on closed server") - } - if obs.Error != errCatRefused { - t.Errorf("Error = %q; want %q", obs.Error, errCatRefused) - } -} - -func TestVerifyURL_TLSCertUntrusted(t *testing.T) { - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - - // A vanilla client with no RootCAs override won't trust the test cert. - // Reuses the SSRF-bypassed transport pattern but with default TLS config. - untrusting := &http.Client{ - Timeout: 5 * time.Second, - CheckRedirect: func(*http.Request, []*http.Request) error { - return http.ErrUseLastResponse - }, - } - matched, obs := verifyURL(context.Background(), untrusting, srv.URL, "pa_t") - if matched { - t.Fatal("matched = true; want false on TLS error") - } - if obs.Error != errCatTLS { - t.Fatalf("Error = %q; want %q", obs.Error, errCatTLS) - } -} - -func TestVerifyURL_SendsUserAgent(t *testing.T) { - gotUA := make(chan string, 1) - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotUA <- r.Header.Get("User-Agent") - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - - verifyURL(context.Background(), testClient(srv), srv.URL, "pa_t") - select { - case ua := <-gotUA: - if ua != userAgent { - t.Fatalf("User-Agent = %q; want %q", ua, userAgent) - } - case <-time.After(time.Second): - t.Fatal("server never received the request") - } -} - -func TestVerifyURL_Timeout(t *testing.T) { - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - time.Sleep(500 * time.Millisecond) - })) - defer srv.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) - defer cancel() - - matched, obs := verifyURL(ctx, testClient(srv), srv.URL, "pa_t") - if matched { - t.Fatal("matched = true; want false on timeout") - } - if obs.Error != errCatTimeout { - t.Errorf("Error = %q; want %q", obs.Error, errCatTimeout) - } -} -- 2.51.2