Skip to content

Latest commit

 

History

History
180 lines (158 loc) · 10.4 KB

remote-access.md

File metadata and controls

180 lines (158 loc) · 10.4 KB

Remote access without Tailscale — device pairing behind a public tunnel

Lectern's normal story for "reach my own control plane from my phone" is Tailscale: internal/auth resolves a caller's identity from tailscaled's LocalAPI, so an owner on the tailnet never logs in at all. That works well until the phone genuinely cannot run Tailscale — a work-managed device, a borrowed one, a network that blocks it. This document is for that case: how to put Lectern behind an ordinary public HTTPS tunnel and still keep it locked down to the one person who owns it.

There are now two ways to do that:

  • Device pairing behind your own HTTPS tunnel (this page). The tunnel (Cloudflare, tailscale funnel) terminates TLS in front of Lectern, so it sees every request in the clear. That is fine when you trust whoever runs the tunnel. An already-authenticated owner mints a short-lived, single-use code; a new device exchanges it once for its own long-lived, individually revocable credential. See internal/pairing's package doc.
  • The end-to-end encrypted relay (relay.md). Lectern and the phone both connect out to a relay that only passes Noise-encrypted frames, so nobody on the path, including whoever runs the relay, can read or change the traffic. It needs no inbound port at all. Use it when you do not want to trust the path, or cannot open one. Happy (github.com/slopus/happy) also relays end-to-end encrypted traffic, with a different design (relay.md compares them).

The quick way: Connect your phone

Settings → Basics → Connect your phone (also at the top of Settings → Phone & devices) lists the addresses a phone could use — the address the browser is already on, the tailnet name, the LAN address (only on the same network, unencrypted) and the relay — each with one line on what it means and, when it cannot work, why and the one step that fixes it. It never offers a loopback address: a QR code for 127.0.0.1 is useless to a phone. Show QR code turns device pairing on if it was off (you pressed the button; that is the deliberate choice) and mints a pairing code for the chosen address. The list comes from GET /api/phone/addresses; when Lectern listens on loopback only (the local runtime from lectern up) the tailnet and LAN rows say so and point at lectern serve.

Setting it up

  1. Turn on device pairing. Either set LECTERN_DEVICE_PAIRING=1 in Lectern's environment, or flip the toggle in Settings → Phone & devices — either one turns it on; the env var cannot be overridden off from the UI (see pairing.Enabled). It is off by default: enabling it is a deliberate choice, not something a bare upgrade turns on for you.
  2. Pick your tunnel. Two options that need no port-forward and no static LAN IP:
    • Cloudflare Tunnel (cloudflared): run it on the same host as Lectern, pointed at Lectern's normal HTTP port (e.g. cloudflared tunnel --url http://127.0.0.1:9110, or a named tunnel with an ingress rule to the same address). Cloudflare terminates TLS at its edge and forwards to cloudflared, which forwards to Lectern over plain HTTP on loopback.
    • tailscale funnel: if this node already has Tailscale (most do — pairing is for the phone that doesn't, not necessarily the server), tailscale funnel 9110 exposes the same port publicly with a real Let's Encrypt certificate, no second daemon needed.
  3. Set LECTERN_AUTH explicitly — do not leave it on auto/tailscale. See "The loopback trap" below for why. LECTERN_AUTH=token with a strong LECTERN_AUTH_TOKEN is the recommended baseline: treat that token as an admin bootstrap secret, used once to mint the first pairing code from a browser or curl, never handed to the phone itself. Pairing is the phone's credential from then on.
  4. Open https://<your-tunnel-hostname>/pair on the phone (or scan the QR code minted in Settings → Phone & devices — see below), pair it, and use Lectern there exactly as you would over Tailscale.

The loopback trap (and how Lectern closes it)

internal/auth trusts a request from loopback as "a process on this machine" (the CLI, the MCP server, a dispatched agent): KindLocal in tailscale mode, and the owner in none mode. A tunnel that forwards to 127.0.0.1 (Cloudflare Tunnel's default, Tailscale Funnel) opens that loopback connection on behalf of every internet visitor, so without a guard they would all inherit that trust.

Lectern refuses loopback requests that show they were relayed from outside, in every mode. That covers any request carrying Cf-Connecting-Ip/Cf-Ray (Cloudflare) or Tailscale-Funnel-Request, or a X-Forwarded-For/X-Real-Ip/Forwarded client address that is neither loopback nor a tailnet IP. Such a request gets in only with a credential: the static token or a paired device. Plain local callers, and tailscale serve relaying a tailnet client, behave as before. The rules and their tests live in internal/auth (cameFromOutside, tunnel_test.go).

Still set LECTERN_AUTH=token with a strong LECTERN_AUTH_TOKEN when you expose Lectern. The guard depends on the tunnel sending a forwarding header, and every mainstream one does. A bespoke proxy that strips all of them would defeat it, and token mode does not extend loopback trust at all.

Security model

With pairing on and LECTERN_AUTH=token set, here is exactly what an anonymous attacker on the public internet can reach:

  • GET /pair — a static page. It renders a form; nothing behind it is reachable without a code.
  • Static assets (/style.css, icons, the manifest, …) — no different from any public web page.
  • POST /api/pair/exchange — the one unauthenticated write. It accepts a code and a device name. Guessing a valid code means guessing 128 bits of randomness (internal/pairing.CodeTTL gives an attacker 5 minutes per code, and internal/pairing.RateLimiter caps attempts at 10/minute per IP and 30/minute globally, with a 5-minute lockout on top of that once tripped) — not a realistic attack. A wrong, reused, or expired code all get the same generic "invalid or expired code" response, so this endpoint is not an oracle for narrowing down a real one.
  • Nothing else. Every other /api/* route and every /term/* route requires either the static token or a valid paired-device credential (a cookie or bearer token that hashes to a row in pairing_devices). Both are opaque, high-entropy secrets stored hashed — the database never holds a value that authenticates anyone on its own (matching internal/oauth's existing convention).

What a paired device can do once paired: everything the owner's browser can, including deciding approvals. This is deliberate, not an oversight — see internal/auth's KindDevice doc comment. A paired phone IS the owner, reachable without Tailscale; it is not a second, lesser account. That is why minting a code requires CanDecide (the same gate an approval decision itself requires) and why revoking a device is instant and final (DELETE /api/pair/devices/{id}, checked on every subsequent request via internal/pairing.Store.LookupDevice).

Trade-offs worth knowing about, not hidden:

  • A device token idles out after LECTERN_DEVICE_PAIRING's configured idle_days (default 30, editable in Settings → Phone & devices) of no requests — but the browser cookie itself is set with a ~400-day Max-Age (the practical ceiling most browsers enforce), so the cookie's own lifetime is never the limiting factor; the server-side idle check, refreshed on every authenticated request, is the real control.
  • CSRF protection (SameSite=Strict plus an Origin/Referer check on every non-GET request authenticated by the device cookie) does not apply to a device token presented as an Authorization: Bearer header — that is a deliberate scope match to the actual risk (a browser tricked into sending a cookie it holds), not an oversight; a script that already has the raw bearer value out-of-band is not the CSRF scenario.
  • Settings → Phone & devices shows a hint when the request reaching it arrived over neither loopback nor a tailnet address while pairing is off (GET /api/pair/settings's untrusted_origin_hint) — but this cannot detect a tunnel that forwards to loopback (see "The loopback trap" above); it is a nudge for the cases it can see, not a complete guarantee.
  • A self-signed certificate on your own reverse proxy (for internal names such as *.example.internal) is irrelevant here: a public tunnel (Cloudflare, tailscale funnel) terminates TLS with its own trusted certificate, so the phone never has to trust anything self-signed.

PWA and push notifications

A paired phone can install the PWA from the public tunnel origin exactly as it would from a tailnet HTTPS origin — service worker registration and the Web Share Target both only require a secure context (https://), which the tunnel provides. Push notification eligibility (frontend/src/push.ts, pushAvailability) checks browser feature support and secure-context status only — it never checks or assumes a tailnet identity, so "Enable phone alerts" works the same way for a paired device as it does for a tailnet browser.

Where Lectern listens

lectern serve (and bare lectern with no terminal, as a service runs it) listens only on 127.0.0.1 unless something protects remote access: a LECTERN_AUTH_TOKEN, or Tailscale identity (automatic when tailscaled is running, or LECTERN_AUTH=tailscale). With one, it listens on every interface as before. An explicit non-loopback LECTERN_HOST with no sign-in is refused at startup, with the fixes listed, unless you pass --insecure-listen (or set LECTERN_INSECURE_LISTEN=1) because something in front of Lectern already checks who is asking. The Docker image listens on the container's interfaces and needs a token or that flag; the Compose file sets the flag while it publishes on the host's loopback only.

Do not expose Lectern without pairing or auth

To say the obvious part plainly: putting a tunnel in front of Lectern with LECTERN_AUTH=none (or auto, which quietly resolves to none off a loopback listener) and pairing off hands unauthenticated control of every session, task, and approval on this box to the entire internet. Pairing is the thing that makes a public tunnel survivable; skipping it is not a smaller version of this feature, it is not having it.