Transports, lifecycle and exposure
The verb surface, the bind address, the start gate, the access log, tunnels, and what is pre-auth by construction.
The verb surface
Bind address: loopback by default, widening is a deliberate act
Default bind is 127.0.0.1:7654 — loopback, never wider. The listen address resolves
through the precedence ladder — --listen flag → SAPHAN_SERVER_LISTEN env →
server.listen config key → built-in default — and the refusal policy binds every tier
alike: a host-less value (":7654") from any source would bind every interface, so it is
refused, class listen-ambiguous, with a paste-ready remedy naming the offending tier.
Configuration cannot widen the bind any more quietly than the flag can. server status
reports the effective address a fresh start would bind, with its provenance tier.
Start gate: no identity, no listener
Before the listener accepts a single connection, the server checks that the runtime
identity registry is provisioned — at least one actor row, at least one binding row, and a
loadable trust-root pin. A missing registry refuses the start by name
(registry: runtime registry not provisioned) with the remediation spelled out
(saphan identity import). This is a deploy-fault check run once, at start, not per
request.
The access log is audit material
--access-log append-opens its path at start; an unwritable path refuses the start, class
access-log-unwritable — a server that cannot append to its audit log must not start.
off disables it deliberately. Under --stdio the default log stream is stderr, because
stdout is the MCP wire. Details of what is logged: section 9.
--detach: never a fork-and-lie
--detach re-execs the binary detached (new session, log redirected) and returns only
after a bounded readiness handshake — the child confirmed alive and holding the pidfile
within two seconds, or a named failure with the log tail. The pidfile is always written by
the foreground child, never by the detaching parent. --detach --stdio is refused (class
detach-stdio-conflict): stdio's whole point is that the invoking terminal owns the wire
for the life of the process, which a detached process can never provide.
--tunnel: reaching the server from outside, for evaluation
⚠ What this is and is not. --tunnel is a convenience for reaching a running server
from outside the machine without changing how it binds — for a demo, an evaluation, or a
developer working against a colleague's instance. It is not the way a production
deployment is exposed. Nothing about it is hidden: it exists, we use it ourselves, and it
is documented here with its limits rather than left as a flag someone discovers.
What it does. It launches cloudflared as a supervised child process fronting the
loopback listener with a quick tunnel.
The server itself never opens a wider bind and never terminates TLS —
cloudflared owns TLS, DNS and network exposure entirely, and every route it forwards
still sits behind the bearer middleware exactly as on loopback. A cloudflared binary
missing from PATH refuses the start by name, never a silent no-op front door.
--tunnel --stdio is refused (class tunnel-needs-listener). One deliberate interaction:
the self-issued authorization server refuses to operate over a forwarded hop — see
section 7.
Pre-auth by construction: the short list
Everything on the HTTP mux is behind the bearer middleware except routes that cannot require a token by their nature:
| Route | Why pre-auth |
|---|---|
GET /.well-known/oauth-protected-resource (+ /mcp twin) | RFC 9728 discovery — the client needs it to obtain a token |
GET /.well-known/oauth-authorization-server | RFC 8414 discovery |
GET /authorize, POST /token | The authorization flow itself |
GET /icon.svg, favicons | Cosmetics — served pre-auth so a connector card can render its icon |
GET /get/install.sh, /get/SHA256SUMS, /get/{platform}/{bin} | Fleet-internal install bootstrap: a machine installing its first saphan binary has no token yet (Install §2) |
GET /dashboard, POST /dashboard/spark | Deliberately pre-auth today; fronting the dashboard with the OAuth surface is named, planned work. The spark endpoint is the dashboard's one write — it files a note through the same signed capture path the CLI uses, with the actor resolved server-side, never taken from the client. |
GET /metrics | Deliberately pre-auth, like the dashboard: a Prometheus scrape target carrying only aggregate counts — class names, never slugs, paths, or actor names (§6.1 below). No token can be required of a scraper, and there are no secret values to protect; treat network reachability as the control. |
The /get/* bootstrap checks its {platform}/{bin} wildcards against closed known sets
before they ever reach a filesystem path — traversal-safe by construction, not by cleaning
— and the install script's self-referencing URL is spliced only from a Host header
matching a strict shape, since that string lands inside a shell assignment about to be
piped to sh.
The metrics endpoint
GET /metrics on the serving daemon is a classic Prometheus text-exposition surface
(Content-Type: text/plain; version=0.0.4), hand-emitted with zero new dependencies. It
sits on the open mux beside /dashboard; a non-GET method answers 405, and a failed
source query answers 500 with the error in the body — never a partial 200 page (a
half-scrape that reads as a healthy small fleet is the failure this surface refuses).
Five families, every one a gauge — the record's rows update in place (a run moves
running → ok), so per-status counts can fall; a counter would misreport a projection of
current state:
| Family | Labels | What it projects |
|---|---|---|
saphan_runs | status, backend | recorded runs, by status and backend |
saphan_refusals | rule | recorded refusals, by rule — first-class, because on this architecture a refusal is a security signal, not noise |
saphan_gates | gate, decision | recorded gate decisions, by gate and decision |
saphan_owner_block_elements | state | owner-block elements, by state |
saphan_store_schema_version | — | the store's current schema-migration version |
Label values are class names only — rule, status, gate, decision, state — never
stream slugs, paths, or actor names. Cardinality is bounded and there is nothing to leak:
secret values are structurally absent (Secrets), and the projection is aggregate
counts, not rows. Values are escaped per the exposition format (\ → \\, " → \").
Scrape it like any Prometheus target:
or check it by hand: curl -s http://<serving-daemon-host>:<port>/metrics.
Security posture. The endpoint is unauthenticated by design — an open-mux projection, the
same posture as /dashboard (§6 table). It exposes no secret values, but it does reveal
fleet shape (counts of runs, refusals, gates), so keep it inside your perimeter and scope
who can reach it. This is the first slice of the observability surface Enterprise deployment
§"Observability and SIEM" describes; the refusal taxonomy and SIEM export land on top of it.
Before you use it, four things it does not give you
- No named tunnel, no account, no DNS you control. A quick tunnel's address is issued for that run and is gone when the process ends. ⇒ It is not an address to put in a document, a client configuration, or a firewall rule.
- No audit of who reached you. Exposure is owned entirely by the tunnel provider: it owns TLS, DNS and the network path. Every route still sits behind the same bearer check as on loopback, so authorisation is unchanged — but the reachability is somebody else's.
- No production posture. ⛔ Do not front a production installation this way. A deployment that must be reachable belongs behind your own proxy, with your own certificate and your own access policy — see the deployment section.
- One deliberate refusal you will meet: the product's own authorization server refuses to operate over a forwarded hop. That is not a bug to work around; minting first-party credentials through somebody else's front door is exactly the shape it declines.
Why it is documented at all, given the above: because it exists in the binary and we use it ourselves. A flag that ships and is not written down is a flag someone finds at the worst moment — and the honest version of "internal convenience" is a page saying so, not silence.