# REMOTE ACCESS — Tailnet-First Runbook Operator runbook for the tailnet-first exposure model shipped 2026-07-01 (SECURITY.md changelog entry of the same date; guard code in [server/remote-guard.ts](../server/remote-guard.ts)). The server binds loopback; the ONLY remote path in is `tailscale serve`. This doc is the how-to — the security rationale and the tracked caveats (SEC-13 global WS broadcast, SEC-14 local header spoof) live in [SECURITY.md](../SECURITY.md). ## Topology ``` iPhone / laptop (tailnet peer) │ WireGuard tunnel — device must be authed into the tailnet ▼ tailscale serve https://..ts.net:443 (ts.net TLS cert) │ proxies to localhost:5001, stamps X-Forwarded-For, │ injects Tailscale-User-Login, STRIPS client-sent Tailscale-* headers ▼ Express :5001 (HOST=localhost) │ remote-guard: X-Forwarded-For OR Tailscale-User-Login present │ ⇒ remote ⇒ default-deny off the allowlist below (+ optional bearer) ▼ /m (PWA shell) · /api allowlist · /ws (?token=) · /mcp (own bearer) ``` Local processes hit localhost directly, carry neither header, and pass the guard untouched — localhost remains the unauthenticated full-control surface (SEC-1). ## One-time setup 1. **Tailscale admin console** (once per tailnet): enable **MagicDNS** and **HTTPS certificates** (DNS page). Without the cert toggle, `serve` can't mint the ts.net TLS cert. 2. **Start the front door** (macOS app bundle ships the CLI inside the app): ```bash '/Applications/Tailscale.app/Contents/MacOS/Tailscale' serve --bg http://localhost:5001 ``` `--bg` persists the config — it survives reboots until `serve reset`. 3. **Check it**: ```bash '/Applications/Tailscale.app/Contents/MacOS/Tailscale' serve status # expect: https://..ts.net (tailnet only) -> http://localhost:5001 ``` ## Env reference (`.env`) | Var | Default | Meaning | |---|---|---| | `HOST` | `localhost` | Server bind address ([server/config.ts](../server/config.ts)). `HOST=` is the rollback to the old LAN-open posture — the guard then treats direct LAN hits as LOCAL (no `X-Forwarded-For`), i.e. fully open again. | | `REMOTE_QUERY_TOKEN` | unset | OPTIONAL bearer for tailnet-remote `/api` requests — defense in depth on top of tailnet device membership, not a replacement. Timing-safe compare; DEDICATED token, never reuse the MCP tokens. Also gates remote `/ws` upgrades as `?token=` (browsers can't set headers on WS upgrades). Unset → tailnet device auth is the only gate. | | `USER_ID` | `local` | Identity stamped on chat + episodic rows for LOCAL requests. Remote requests resolve **session-first** (`mts_` bearer/cookie), then the serve-injected `Tailscale-User-Login` (unless aliased, below) — body/query `userId` is never honored remotely. A remote request WITHOUT identity (tagged tailnet node, `tailscale funnel`, foreign reverse proxy) is **rejected 403** (SEC-15 closed 2026-07-04; `/api/health` exempt). | | `REMOTE_USER_ALIASES` | unset | Comma-separated Tailscale login names that ARE the operator (2026-07-04). These remote logins resolve to `USER_ID` so the operator's own phone lands on the SAME identity as local sessions — otherwise their chat threads + episodic memories silently fork under the tailscale login. Case-insensitive. Example: `REMOTE_USER_ALIASES=`. | | `SESSION_TTL_DAYS` | `30` | Sliding TTL for `mts_` auth sessions ([server/auth/sessions.ts](../server/auth/sessions.ts)). See "Sessions" below. | | `LOCAL_AUTH_REQUIRED` | unset | SEC-1 enforcement: `1` requires a session on LOCAL `/api` + `/ws` too (operator scope for mutating endpoints; bootstrap via `tools/issue-session.ts`). Leave OFF until the 5173/4174 UIs carry tokens. | | `REMOTE_ALLOW_ANONYMOUS` | unset | Map identity-less remote requests to the `remote-anonymous` sentinel instead of 403 (it can never equal `USER_ID`). Leave OFF unless you deliberately want anonymous traffic. | | `CHAT_STREAMING` | on (`0` disables) | Existing toggle — chat tokens stream over `/ws` as `chat_stream_*` events; the mobile PWA chat tab consumes them. Since 2026-07-04 frames are routed per-user (SEC-13 closed): each socket binds an identity at upgrade and only receives its own user's streams (operator/owner sockets see everything). | ## Sessions (SEC-2, 2026-07-04) Durable per-user credentials on top of tailnet device auth ([server/auth/sessions.ts](../server/auth/sessions.ts); probes: `npx tsx tools/verify-sessions.ts`). - **Mint (remote)**: `POST https://.ts.net/api/auth/session` — exchanges the serve-injected tailnet identity for an `mts_` token (`user` scope). Response carries the token ONCE and sets the `metatron_session` HttpOnly cookie, which rides all same-origin PWA fetches and the `/ws` upgrade automatically. Programmatic callers send `Authorization: Bearer mts_...`; WS fallback `?session=`. - **Mint (local/operator)**: `npx tsx tools/issue-session.ts [--user u] [--scope operator|user] [--label l] [--ttl-days n]` (direct DB — works under enforcement), or `POST /api/auth/session` from loopback while `LOCAL_AUTH_REQUIRED=0`. - **Inventory / revoke / prune**: `GET /api/auth/sessions`, `POST /api/auth/revoke {sessionId}` (operator, localhost-only), or the CLI `--list` / `--revoke ` / `--prune`. - A valid session **satisfies the `REMOTE_QUERY_TOKEN` bearer gate** — the shared token remains as the sessionless-phone fallback and can be retired per-device as sessions roll out. ## Build + reach the PWA ```bash npm run build:mobile # vite-mobile.config.ts → client-mobile/dist ``` Express serves the build same-origin at `/m` (mount is conditional on `client-mobile/dist` existing — build first, no server restart needed for rebuilds, the static middleware reads from disk). Dev loop: `npm run dev:mobile` (:5175, same `/api` + `/ws` proxies as the other two apps). On the phone (tailnet-joined, Tailscale app connected): 1. Open `https://..ts.net/m/` — the bare origin `/` also 302-redirects remote clients to `/m/`. 2. If `REMOTE_QUERY_TOKEN` is set: Settings sheet → paste the token. It rides every `/api` call as a bearer and forces a WS re-dial with `?token=`. 3. Share → **Add to Home Screen** — installs the standalone PWA (`manifest.webmanifest` + `sw.js` offline shell). **Documents (2026-09-26).** Settings → Documents uploads a PDF/text file and then shows what actually happened to it (queued / preparing / ingesting k of N / embedded / failed / unknown), polled from `GET /api/ingest/status`. The section is hidden unless `GET /api/system/status` reports `capabilities.ingest` — the same predicate the upload middleware enforces — so a guest never sees it and an account off `REMOTE_INGEST_ALLOW` is told why. Note the screen states two things the uploader cannot otherwise know: the source FILE is deleted after `INGEST_SOURCE_TTL_DAYS` (upload is not storage), and with `INGEST_VALIDATOR_ENABLED=0` uploads are not LLM-screened. **Images (2026-09-26).** The composer has an image button beside the text input. It is shown only when `GET /api/image/status` reports `configured && allowed` (the `canGenerateImages` rule), so anonymous guests never see the one control that spends money per call. Turning it on sends the next message straight to `POST /api/image/generate` — no retrieval, no blend, no grounding gate — and the server writes both turn rows, so the image appears in the transcript through the normal markdown path and survives a reload. Generation is slow by nature (~57 s warm, +~49 s if the GPU is asleep), so the composer shows a live elapsed counter naming the phase rather than a bare spinner. Asking for a picture in plain words still works too: the dsh harness has its own `generate_image` organ and pastes the link into its answer. ## The remote allowlist Exact-path, method-keyed, default-deny ([server/remote-guard.ts](../server/remote-guard.ts) — paths are decoded + posix-normalized before matching; traversal/null → 403). Everything not listed (admin, reset, ingest, training control, engine state, episodic API, …) is 403 for remote callers. | Method | Path | Notes | |---|---|---| | GET | `/api/health` | | | GET | `/api/init/status` | | | GET | `/api/messages` | remote force-scoped to the caller's identity (session or tailnet login; `?userId=` ignored) | | GET | `/api/conversations` | same force-scoping | | GET | `/api/cubes/registry` | | | GET | `/api/cubes/topology` | | | GET | `/api/metrics/latest` | | | POST | `/api/chat` | full pipeline; `user_id` stamped on chat + episodic rows; foreign `conversationId` → 403 (SEC-16); episodic recall scoped to the caller (SEC-17) | | POST | `/api/auth/session` | session mint — exchanges tailnet identity (or an existing session) for an `mts_` token | | POST | `/api/query/cross-cube` | read-only flat Lance HNSW across all ready cubes — no LLM, no mutation | | POST | `/api/diagnostics/cube-query` | read-only forward pass | | POST | `/api/ingest/stage` | document upload (desktop UI + the `/m` PWA since 2026-09-26). Allowlisted here, gated in-handler by `requireIngestAllowed` — operator scope or an account on `REMOTE_INGEST_ALLOW`; guests and unlisted accounts get a clean 403. `/api/ingest/pdf` is deliberately NOT allowlisted | | POST | `/api/ingest/url` | URL crawl — same in-handler gate | | GET | `/api/ingest/status` | per-uploader ingest progress (2026-09-26). SAME `requireIngestAllowed` gate as the upload; returns ONLY the caller's own uploads. Exists because a 200 from `stage` is not an ingest, and neither `/api/admin/executor/status` (remote-denied, and global) nor WS frames (`scope: 'user'` receives none) can tell a phone the truth | | GET | `/api/system/status` | public engine status + the caller's own `capabilities.ingest` | | POST | `/api/image/generate` | image generation — signed-in only, per-identity quota (the one route that spends money per call) | | GET | `/m`, `/m/*` | PWA static shell — no bearer (the shell is not secret) | | GET | `/` | 302 → `/m/` for remote clients | | — | `/mcp`, `/mcp/*` | passthrough — carries its own bearer (two-tier MCP auth) | OPTIONS on allowlisted paths passes and is bearer-exempt (preflights can't carry Authorization). The bearer applies to the `/api` rows only — never `/m*`, never `/mcp*`. Since 2026-07-04 every allowlisted `/api` request except `GET /api/health` additionally needs an identity — a session or a tailnet login (SEC-15). ## External consumers (M2) — MCP migration Point the M2 consumer at `https://..ts.net/mcp` instead of a LAN address. Nothing else changes: the MCP bearer (`MCP_READONLY_TOKEN` for external consumers) is transport-independent, and `MCP_BIND_LOCALHOST_ONLY=1` stays set — the localhost IP gate in [server/mcp/auth.ts](../server/mcp/auth.ts) checks the socket address, which IS localhost for serve-proxied requests (no `trust proxy` configured), while direct LAN hits keep failing the loopback bind entirely. ```bash claude mcp add --transport http metatron https://..ts.net/mcp \ --header "Authorization: Bearer $MCP_READONLY_TOKEN" ``` ## Verification ```bash BASE=http://localhost:5001 TOKEN= npx tsx tools/verify-remote-access.ts ``` 9 probes: local baseline, remote allowlisted 200, off-allowlist 403s (including `POST /api/reset` and `POST /api/admin/shutdown` — which MUST 403 at the guard before routing), the `/` → `/m/` redirect, cross-cube reachability, and the 401/401/200 bearer ladder when `TOKEN` is set. **WARNING**: only run against a server built after remote-guard landed — on an older build the shutdown probe reaches the real handler and cleanly shuts the server down. **Second-device-free guard testing**: the guard marks a request remote when `X-Forwarded-For` is present (serve always stamps it), so any local curl can simulate a tailnet peer: ```bash curl -s -H 'X-Forwarded-For: 203.0.113.x' http://localhost:5001/api/engine/state # → 403 curl -s -H 'X-Forwarded-For: 203.0.113.x' http://localhost:5001/api/health # → 200 (or 401 if bearer set) ``` (Same mechanism as the verify tool. This local-spoof property is the accepted SEC-14 caveat — see SECURITY.md.) End-to-end: open the ts.net URL from an actual second device on the tailnet. ## Rollback ```bash # Close the front door (removes the serve config): '/Applications/Tailscale.app/Contents/MacOS/Tailscale' serve reset # Restore the old LAN-open posture (SECURITY.md pre-2026-07-01): # .env → HOST=, then restart the server. ``` The two are independent: `serve reset` alone leaves a loopback-only server (no remote access at all); `HOST=` alone re-opens the LAN while the tailnet path keeps working.