Observer's dashboard is loopback-only by default (127.0.0.1) — the security
model is single user + loopback. Remote access is opt-in and off by
default. This page covers the v1 remote transport: viewing the dashboard
over your private Tailscale tailnet, with real HTTPS
and application authentication.
Scope of v1 (operator decision 2026-07-12):
- Tailnet-HTTPS only. Native LAN TLS (binding a LAN IP directly) is a tracked future feature, not shipped.
- View tier by default. You can read the dashboard remotely. The execute
tier (remote terminal / launches) is a separate, default-off opt-in
(
[remote].allow_terminal) with its own security review — enabling remote view never turns it on. Remote viewing of attach/resume PTYs is separately gated by[remote].allow_terminal_view(default true, mirroring theallow_remote_terminal_takeoverdefault-true precedent): a paired device sees those PTYs read-only — including their rows in the dashboard's Jump-in list. Fresh/handoff dashboard terminals are the non-sensitive floor a remote caller always sees; only the attach/resume PTYs (whose TUI can echo secrets) are gated. This is a READ-only relaxation — driving them is unchanged and still requiresallow_terminalplus the full writer conjunction. Terminal output may contain secrets, so setallow_terminal_view = falseto keep the attach/resume sessions (and their Jump-in rows) hidden from remote callers. tailscale serve(private), nevertailscale funnel(public). Funnel exposes to the public internet; Observer refuses execute routes over funnel and the whole feature is designed around the private tailnet only.
See docs/plans/remote-dashboard-access-plan-2026-07-12.md for the full design
and threat model, and docs/security.md for the ledger entry.
Everything below can be driven from the Remote page of the local dashboard
(Configure → Remote) — you rarely need the CLI. The page is owner-local: the
management actions are CapabilityLocal, so they only work from the machine
running Observer (a paired remote viewer sees state but cannot arm/pair/reset).
-
Turn on remote access — mints the pairing secret + reserves the loopback backend + writes
[remote]. Binds on the next daemon restart (there's a Restart daemon button inSettings → Health, and a "Restart now" button on the restart-pending banner — no CLI jump needed). -
Pair a device — the everyday action. Shows a one-time QR + link for a new phone/laptop. Devices you've already paired stay connected — pairing a new device never disconnects the others. You can pair up to
max_sessionsdevices (default 5). Takes effect immediately (the running controller hot-reloads the fresh secret — no restart).Pairing is gated on tailnet reachability so you never mint a QR that can't connect. The button reflects the same
tailscale statusthe Tailscale card reads:- Reachable (Tailscale up + serve confirmed exposing the backend) → the button is live, as normal.
- Known-unreachable (Tailscale absent, logged out, or serve not configured) → the button is visible but disabled, with an inline hint naming the exact missing step (install Tailscale / log in to Tailscale / start Tailscale serve) and a link that scrolls to the Tailscale card to finish it.
- Indeterminate (an older
tailscaleCLI can't report serve state, or the status probe failed) → the button stays enabled — remote never hard-blocks on a signal it can't read — with a caution line: if the pairing link doesn't load on the device, finish the Tailscale serve step.
On the device itself: the phone/laptop you scan from must have Tailscale installed and be signed into the same tailnet account as this machine — otherwise the
https://<host>/#pair=…URL won't resolve at all. The reachable Tailscale card and the pairing-QR reveal both show the app links: iOS · Android · other platforms. -
Reset & unpair all devices — the rare, destructive control. Mints a new secret and disconnects every paired device (each must scan a new QR). Use it only if a secret leaked. Confirmed with a device-count warning.
-
Turn off remote access — reverts to loopback-only and removes the secret (needs a restart to unbind the listener).
The Tailscale card on the Remote page walks the whole tailnet setup as a guided state machine — install → log in → arm → serve → pair — with each manual step now runnable from an in-dashboard terminal (you never leave the dashboard):
- Not installed → Install in a terminal runs Tailscale's official
install.shundersudoin an embedded xterm (Linux only; refused if tailscale is already present). The download link remains as a fallback. - Installed, logged out → Log in in a terminal runs
tailscale upin an embedded xterm and shows the authentication link it prints — open it on your phone/browser to approve the machine. (sudo-prefixed unless the daemon runs as root; a user who already holds Tailscale operator rights could run it unprivileged.) Copy-the-command remains as a fallback. - Armed, serve not set → Set up serve for me (and, if the unprivileged daemon needs it, a one-time operator grant terminal). If your tailnet hasn't approved HTTPS/Serve yet, this surfaces a one-time enable Serve consent link to your Tailscale admin console. Approval alone does not start serving — after you approve, come back and click Set up Tailscale serve for me again (a Retry serve button sits right beside the consent link; it re-fires the same request and refetches status when it completes).
Each of these terminals is owner-local only: the spawned command is a fixed,
server-derived argv (never request input), the route is CapabilityLocal +
confirm-token, and the PTY is a local-writer-only setup session — a paired remote
device can never drive it. The sudo/auth flow happens interactively in the
xterm; Observer never stores or handles your password.
Paired device sessions persist across daemon restarts (node-local
remote_sessions, migration 066) — restarting the daemon (e.g. via the dashboard
button) no longer logs your phone out. The raw session token lives only in the
device's cookie; a leaked observer.db yields no usable cookie.
Because the secret is stored hashed-only (§ Security notes), the same QR can never be re-shown — "Pair a device" always mints a fresh one. That's why adding a device and resetting are different actions: adding keeps existing devices, resetting replaces the secret for everyone.
tailscale serve terminates TLS on your tailnet and forwards plaintext to a
loopback backend. Observer therefore does not embed Tailscale — the
operator runs tailscale serve, and Observer serves a dedicated loopback
listener behind it:
phone/laptop (tailnet) --HTTPS--> tailscale serve --plaintext--> 127.0.0.1:<backend> (Observer)
The backend listener is a separate listener from the owner-trusted direct
dashboard listener. It is classified remote-exposed at construction: it
requires authentication for every request — even though the peer is
loopback — so there is no "it came from 127.0.0.1, therefore trusted" bypass
(the tailnet-serve-to-loopback trap). It is also the only place forwarded
identity headers (Tailscale-User-Login, …) are read; they are stripped before
any handler runs so a spoofed copy can never be trusted. In v1 those headers
are recorded for audit only — authentication is the pairing device session, not
the tailnet identity.
-
Install Tailscale and join your tailnet on the machine running Observer. Confirm the machine's HTTPS host with
tailscale status(it looks likemy-machine.your-tailnet.ts.net). HTTPS certs require MagicDNS + HTTPS enabled in your tailnet. (On Linux you can do both the install and thetailscale uplogin from the dashboard's Tailscale card instead — see Managing it from the dashboard above.) -
Arm remote access:
observer remote enable --tailscaleThis is an atomic transaction: it mints a 128-bit pairing secret (stored hashed at rest, argon2id,
0600), reserves a dedicated loopback backend port, adds your tailnet host to the Host allow-list, and writes[remote]. It auto-detects the tailnet host viatailscale status; pass it explicitly if detection fails:observer remote enable --tailscale --host my-machine.your-tailnet.ts.netThe command prints a pairing URL with the secret in the URL fragment (after
#) — the fragment is never sent to or logged by the server — plus thetailscale servecommand to run and a restart reminder. -
Point Tailscale at the backend (once, on this machine). The
enableoutput prints the exact command, e.g.:tailscale serve --bg :<backend-port> -
Restart the observer daemon so the backend listener binds. Follow the daemon-restart order (route OFF → stop →
observer start→ route ON) fromdocs/daemon-restart-runbook.md— the running daemon is not hot-restarted byenable(that would break live proxied sessions). -
Pair from your device (on the same tailnet): open the printed
https://<host>/#pair=<secret>URL. The dashboard reads the fragment, pairs, receives a short-lived HttpOnly session cookie, and strips the hash from the URL. You now have view-tier access.
observer remote status # mode, backend, trusted hosts, TLS, recent access events
observer remote rotate # RESET: mint a fresh secret; invalidates EVERY paired device
observer remote disable # revert to loopback-only AND remove the pairing secret
CLI rotate/disable run in a separate process, so they take effect on the next
daemon restart (the running process loaded the previous secret at startup).
disable removes the secret file (true revocation) and reverts [remote] to
off.
The CLI has no "add a device" equivalent —
observer remote rotateis the reset-everyone control. To add a device without disconnecting the others, use the dashboard's Pair a device button (it hot-reloads the fresh secret on the running controller, so it takes effect immediately — no restart). The dashboard "Reset & unpair all devices" is the equivalent of CLIrotate.
[remote] is node-local — it is never distributed via [org_client.share] and
there is no server-side/remote toggle for it (mirrors the org-push posture: the
node operator owns exposure entirely). observer remote enable manages these;
you rarely edit them by hand.
[remote]
enabled = true # master switch (default false)
mode = "tailscale" # off | tailscale (lan is deferred)
tailscale_backend_addr = "127.0.0.1:PORT" # loopback backend tailscale serve forwards to
trusted_hosts = ["my-machine.your-tailnet.ts.net"] # Host allow-list (no "allow any")
require_tls = true # TLS required for all remote access
allow_terminal = false # execute-tier terminal (Phase 4; separate opt-in)
allow_terminal_view = true # read attach/resume PTYs remotely (independent, default true; set false to hide)
allow_remote_terminal_takeover = true # authenticated remote may supersede local/remote writer
rate_limit_per_min = 6 # pairing-attempt rate limitallow_remote_terminal_takeover is a post-authentication lease policy, not an
authentication bypass. With its default true, a remote device that has already
passed the full writer conjunction (Tailscale HTTPS, paired live device session,
allow_terminal, launch/session policy, and a valid single-use
capability+confirm or standing secret) may take control from the native/local
seat or another remote device. The losing seat stays connected read-only and is
offered take-back. Set it to false (or turn off “Allow remote devices to take
over control” under Terminals → Settings) to require the current writer to yield.
A valid one-time capability is consumed before that lease-policy refusal, so a
fresh approval is required for the next attempt; a standing secret is not
cleared. The credential gate itself is unchanged.
The pairing secret is not in config — it lives hashed in a 0600
remote-secret file beside the DB.
Fresh-launch default directory. When a fresh terminal launch has no
project root (none allow-listed, or the operator picks "Agent's default
directory"), the agent runs in the Observer daemon's own working directory —
the directory observer start / observer dashboard was launched from — not a
guessed project. Allow-list a root under [terminal.launch].allowed_project_roots
(Terminals page → launch policy) to launch elsewhere.
Such a launch still correlates to its agent session: run→session discovery matches on the directory the child actually runs in (the daemon's cwd), not on the authorized project root, so the Session panel links and the live vitals fill in exactly as they do for an allow-listed launch.
It also gets the Files / Git panel. Since 2026-08-28 (operator ruling,
reversing the earlier conservative gating) those buttons are enabled by
default for any dashboard-launched terminal that has a directory on this
machine: they follow the run's factual working directory when the launch
requested no root, and the allow-listed project root when it did. The launch
allow-list governs which roots a dashboard client may request at launch time;
it was never a statement about which directory an already-running, owner-local
terminal may browse. Nothing about who may browse changed: the panel is still
token-scoped and server-resolved (the browser never sends a path), still applies
its per-request traversal/symlink containment checks, and a remote-exposed viewer
still needs [remote].allow_terminal_view.
The panel says which kind of directory it is showing. When it is serving the
terminal's working directory rather than an allow-listed project root, the header
reads <tool> · working dir and the path is labelled working directory.
Files/Git stay disabled only when there is genuinely nothing local to browse:
an SSH remote-system terminal (its files live on the remote host, and the
local directory its ssh client runs in is not the terminal's working
directory), or a daemon whose own working directory could not be read. If a
terminal shows a live session but greyed-out Files/Git buttons, that is one of
those two states, not a failure.
- A paired remote viewer sees the full, unscrubbed local
observer.db— they are trusted as the machine owner. A lower-trust remote viewer is a separate scrubbing project, not this feature. - Attach/resume terminal subscription is governed by its own gate,
[remote].allow_terminal_view, independent ofallow_terminal. It now defaultstrue(a paired device sees those PTYs read-only), mirroring theallow_remote_terminal_takeoverdefault-true precedent; the WRITE/drive path is unchanged. The gate covers BOTH the live PTY subscription AND those PTYs' rows in the dashboard's Jump-in list; fresh/handoff dashboard terminals are the non-sensitive floor a remote caller always sees, so with the gate off a remote Jump-in list shows only fresh/handoff. Setallow_terminal_view = falseto restore the deny-read posture for the attach/resume PTYs. Now that everyobserver <verb>launcher attaches by default, more sessions areKindAttachand therefore fall under this gate — intended, not a regression: fresh/handoff dashboard terminals remain the non-sensitive floor regardless. - The
remote_auditlog is metadata-only (session ids and enums, never secrets) and is not compliance-grade immutable — a local owner can mutate the SQLite file. It is a best-effort operational record, not tamper-evident. - On Windows, the
0600secret-file permission is advisory; file ACLs are the real control (documented residual). enablereserves a free loopback port at arm time and the daemon rebinds it on start; if that port is taken at start, the backend listener logs a clear error and the local dashboard is unaffected.