diff --git a/docs/service-contract.md b/docs/service-contract.md index 215e5e7..429bee9 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -1,52 +1,56 @@ -# Service contract and architecture +# Endpoint Monitor service contract -## Resource and lifecycle +## Resources and lifecycle -A monitor has a server-generated UUID, name, absolute HTTP(S) URL, expected HTTP status, -timestamps, and a current status. State is process-local memory guarded by one `asyncio.Lock`; -it starts empty on every process start, is not shared across workers, and is lost on restart. -Run exactly one worker unless an external store is added. Returned models are frozen so state -cannot be mutated outside the store lock. +A monitor has an immutable UUID, `name`, HTTP(S) `url`, timestamps, and a current +check snapshot. New and URL-updated monitors are `unknown`. State is process-local +memory, protected by one async lock, and is lost on restart. Multiple workers do not +share state. No authentication is provided; deploy behind an authenticated gateway. -## HTTP contract +## HTTP API -| Method | Path | Success | Purpose | -|---|---|---:|---| -| POST | `/monitors` | 201 | Create | -| GET | `/monitors` | 200 | List | -| GET | `/monitors/{uuid}` | 200 | Retrieve | -| PATCH | `/monitors/{uuid}` | 200 | Partial update | -| DELETE | `/monitors/{uuid}` | 204 | Delete | -| POST | `/monitors/{uuid}/check` | 200 | Run and atomically persist a check | -| GET | `/monitors/{uuid}/status` | 200 | Retrieve current status | -| GET | `/health/live` | 200 | Process liveness | -| GET | `/health/ready` | 200 | In-memory service readiness | +| Method and path | Success | Notes | +|---|---:|---| +| `POST /monitors` | 201 | Create; duplicate names are allowed | +| `GET /monitors` | 200 | Stable creation-order list | +| `GET /monitors/{id}` | 200 | Retrieve | +| `PUT /monitors/{id}` | 200 | Full update | +| `DELETE /monitors/{id}` | 204 | Delete | +| `POST /monitors/{id}/check` | 200 | Run one bounded check and atomically publish it | +| `GET /monitors/{id}/status` | 200 | Current snapshot; no network call | +| `GET /health/live` | 200 | Process is serving HTTP | +| `GET /health/ready` | 200 | Configuration and in-memory store are available | -Missing resources return `{"error":{"code":"not_found","message":"monitor not found"}}` -with 404. Invalid UUIDs or bodies return a generic, input-redacting `invalid_request` with 422. -There is intentionally no authentication. +Missing monitor IDs return `404 {"detail":"monitor not found"}`. Validation failures +use FastAPI's 422 response. A check transport failure is a successful invocation and +returns a snapshot with `error`; an unsafe target returns `blocked`. These are not API +5xx responses because the service completed the requested check. -Status meanings: `never_checked` has no observation; `up` exactly matches `expected_status`; -`down` is a completed nonmatching HTTP response; `error` is timeout/protocol/network failure; -`blocked` means outbound policy rejected the destination. Check failures are check results (200), -not API transport failures. A monitor deleted while its check runs yields 404 rather than being -recreated. +## Status semantics -## Outbound security and logging +`unknown` means never checked or URL changed. `up` means final status 200-399 after +safe redirects. `down` means final status 400-599. `error` means timeout, DNS failure, +or HTTP transport failure. `blocked` means SSRF policy rejected a host/address or +redirect. Snapshots contain completion time, latency in milliseconds, optional HTTP +status, and a bounded non-sensitive error code. -Only HTTP(S), credential-free URLs are accepted by the checker. Every initial and redirected -hop is resolved immediately before request; all returned addresses must be globally routable. -Redirects are handled manually and bounded, requests have bounded timeouts, bodies are streamed, -and exceptions map to stable non-sensitive messages. This blocks direct, DNS, and redirect -attempts to loopback/private/link-local/reserved addresses. Like most application-level DNS -checks, there remains a small resolver-to-connect rebinding race; production high-assurance -deployments should additionally enforce an egress proxy/firewall. +## Check security and consistency -Structured JSON check logs include IDs, status, latency, and scheme/host/port/path only. Query, -fragment, credentials, response bodies, and raw exception text are excluded. +Only HTTP(S), no URL credentials, and a non-empty hostname are accepted. Every hop is +resolved before I/O; every resolved address must be globally routable. Redirects are +followed manually and revalidated, with a configured maximum. Timeouts and redirect +counts are bounded. The store uses a revision compare-and-set so a result from an old +URL cannot overwrite a concurrent update. This DNS preflight mitigates ordinary SSRF; +deployments requiring protection from DNS rebinding should also enforce egress policy +at the network layer. + +Logs are JSON and include monitor ID, outcome, latency, and a URL with credentials, +query, and fragment removed. Raw exception text and redirect locations are never +logged. ## Layout -`app/api.py` owns HTTP semantics, `models.py` schemas, `store.py` locked state, `checker.py` check -orchestration, `security.py` outbound policy, and `config.py` environment settings. Tests isolate -outbound traffic with `httpx.MockTransport`. +`app/` contains settings, models, locked store, SSRF validation, checker, logging, and +FastAPI composition. `tests/` contains API/unit security tests. Packaging is in +`pyproject.toml`, `Dockerfile`, and `compose.yaml`; operational evidence is in +`VERIFICATION.md`.