feat: serve live sessions to other Tailscale devices via Tailscale Serve - #216
feat: serve live sessions to other Tailscale devices via Tailscale Serve#216ss251 wants to merge 7 commits into
Conversation
Reviewing an artifact required sitting at the machine that owns the loopback server: session URLs are 127.0.0.1-based, so a phone or tablet cannot open the live review surface. `share` does not fill that gap - it publishes a static snapshot to a third-party host with no annotation SDK and no feedback loop - and hand-rolled tunnels are unreliable and expose an unauthenticated server whose session keys derive from file paths. `lavish-axi tailnet` keeps the server loopback-bound and lets tailscaled terminate TLS on the tailnet, proxying to loopback locally. The serve config is scoped to its own HTTPS port so an existing serve config on :443 is never touched, the device's MagicDNS name joins the Host allowlist only while enabled (DNS-rebinding protection stays on), and session output gains a tailnet_url an agent can hand the user directly. Public exposure via funnel is deliberately unsupported: the tailnet's device authentication is the access control. Verified live on a tailnet that already served :443 - both configs coexisted, the tailnet session URL returned 200, a spoofed Host returned 403, and --off cleared only the feature's own port. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3747908 to
323fa2b
Compare
|
Automated reminder: thanks for the PR! This branch currently has a merge conflict with the base branch. When you get a chance, please rebase onto (or merge) the latest base branch, resolve the conflict, and push. After that, checks will re-run and the PR will get looked at again. Noted for lavish-axi#216 at |
|
Speaking as Kun's firstmate: the |
kunchenguid
left a comment
There was a problem hiding this comment.
Speaking as Kun's firstmate: taking back the product flag. The lavish-axi tailnet command shape is fine (opt-in, named, disclosed, loopback-bound). What blocks merge is the implementation.
Please fix these before this is mergeable:
- Same-origin rewrite must not ignore scheme for every request. Gate the rewrite to Tailscale MagicDNS, not a global scheme-blind compare.
- A tailnet peer who has a session URL must not get an unauthenticated live session. Path-derived keys are not enough once the page is reachable off loopback.
- Rebase onto current main. This fork would drop session-bound whiteboard tokens,
frame-ancestors, realpath asset confinement, prompts CSRF, and attachments.
Holding here until those land. Not a product call.
|
Speaking as Kun's firstmate: restamping hold without re-flagging. This stays a captain-decision hold (opt-in Tailscale Serve shape is VISION-aligned, but same-origin/security and fork-behind-main concerns remain). Main now has automatic Tailscale phone binding via #289 — a different approach than Serve; #215 still names this Serve path as the existing help. Still CONFLICTING. No captain re-flag. |
What Changed
lavish-axi tailnet(--status/--off) so live sessions are reachable from other devices on the user’s Tailscale network: the server stays loopback-bound whiletailscale serveproxies a dedicated HTTPS port (default8443,LAVISH_AXI_TAILNET_HTTPS_PORT), MagicDNS joins the Host allowlist only while enabled, and enable/disable restarts the detached server to pick that up.tailnet_url(https://<magicdns>:<port>/session/<key>); serve config is port-scoped so existing Tailscale Serve setups (e.g.:443) are left alone, and public funnel exposure is unsupported.tailscale servedo not 403 whiteboard, share, or chrome-handoff POSTs; enable/disable paths validate the optional file and serve target port first, and roll back by restoring priortailnet.json/ serve when a later step fails.Risk Assessment
✅ Low: Prior review defects (TLS same-origin breakage, enable atomicity, port-change orphans, rollback prior-state loss) are fixed at the shared boundaries; remaining risk is the intentional tailnet trust model (unauthenticated loopback server behind WireGuard device auth), not open source bugs.
Testing
Exercised the full user path for
lavish-axi tailneton a signed-in device: enable produces a working HTTPS MagicDNS session URL while the server stays loopback-bound, DNS-rebinding Host guard accepts only the allowlisted hostname, open output includestailnet_url, and--offremoves both the port-scoped serve proxy and the allowlist entry; focused unit tests for pure helpers and same-origin TLS-terminator matching also passed.Evidence: tailnet enable CLI output with tailnet_url
tailnet: active: true hostname: devcube.tail9a8fe8.ts.net https_port: 8443 base_url: "https://devcube.tail9a8fe8.ts.net:8443/" session.tailnet_url: "https://devcube.tail9a8fe8.ts.net:8443/session/ed7ce2e0e4363fca"Evidence: HTTP checks while tailnet enabled
local=200 spoof=403 magicdns_host=200 tailnet_session=200 health={"ok":true,"app":"lavish-axi","version":"0.1.45"}Evidence: Lavish chrome HTML served over MagicDNS HTTPS
/var/folders/nc/j5wdfhws2q56jzdcj_jkdvww0000gn/T/no-mistakes-evidence/01KYYAJMEPE8NVKR9BJFC9Q77Q/07-tailnet-session-chrome.png)Evidence: open while enabled includes tailnet_url
session.url: http://127.0.0.1:43991/session/ed7ce2e0e4363fca session.tailnet_url: https://devcube.tail9a8fe8.ts.net:8443/session/ed7ce2e0e4363fcaEvidence: tailnet --off and post-off checks
active: false; after off: local=200, magicdns_host=403, tailnet_url=connection_refused, spoof=403Evidence: E2E transcript summary
Pipeline
Updates from git push no-mistakes
⏭️ **intent** - skipped
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
🔧 **Review** - 5 issues found → auto-fixed (4) ✅
src/server.js:1191-isSameOriginRequestbuildsexpectedOriginas${req.protocol}://${Host}.lavish-axi tailnetalways configurestailscale serveto proxyhttps://<magicdns>:<port>→http://127.0.0.1:<appPort>(TLS terminates in tailscaled; Node still sees plain HTTP and has notrust proxy/X-Forwarded-Protohandling). A phone/tablet browser therefore sendsOrigin: https://…while Express expectshttp://…, so every same-origin-guarded route returns 403: whiteboard channel/save/feedback-files, browser Publish/share, and chrome handoff recovery (POST /api/:key/chrome-loads/begin). Element/text prompts and poll still work (unguarded), but Mermaid whiteboard review on the advertised tailnet path is broken. Live verification in the commit only covered GET 200 + Host allowlist, not these POSTs. Fix at this shared boundary (e.g. compare URLhostincluding port, or honorX-Forwarded-Protowhen Host is already allowlisted)—do not paper over individual routes.src/cli.js:651- Enable writestailnet.jsonthen force-restarts the server beforetailscale servesucceeds. IfensureServerthrows, state stays active with no proxy. If serve fails, state is deleted but the already-restarted process still has the MagicDNS name in its Host allowlist until another restart—no compensatingforceRestartafter rollback. Make enable atomic: configure serve first (or roll back state and re-restart without the allowlist on any failure).src/cli.js:618---offignoresspawnSyncstatus and still deletestailnet.json/ restarts when Tailscale is missing orserve … offfails, leaving an orphaned port-scoped serve mapping. Surface the failure (or retry) so operators know proxy cleanup did not complete.src/cli.js:649- Re-runninglavish-axi tailnetafter changingLAVISH_AXI_TAILNET_HTTPS_PORTconfigures only the new port and never clears the previous stored port.--offthen clears only the new port, so the old HTTPS serve entry keeps proxying the unauthenticated loopback server on the tailnet. On enable, if prior state has a differenthttpsPort, runbuildServeOffArgsfor the old port first.src/cli.js:652- Enable/disable alwaysforceRestarts the detached server, which drops in-flightlavish-axi polllong-polls and browser SSE without calling that out innext_step. Acceptable tradeoff for allowlist pickup, but agents mid-session will need an explicit re-poll.🔧 Fix: Fix tailnet same-origin and enable atomicity
2 warnings still open:
src/cli.js:699- Enable completes serve config, writes tailnet.json, and force-restarts the server before resolving the optional file argument viacanonicalFile(realpath). A missing/invalid path throws after those side effects, so the command looks failed while tailnet access is already ON (serve live, state present, allowlist applied). Resolve/validate the optional file before enable side effects, or catch and still return the successful enable payload (existing no-session messaging already covers a path with no open session).src/cli.js:679-buildServeArgsusesflagValue(args, "--port") || defaultPort(), butensureServeralways binds/restarts ondefaultPort()only, and help does not document--portfor tailnet. Passing a non-default--portpoints Tailscale Serve at a port the CLI is not running, so advertisedtailnet_urls fail while local sessions on LAVISH_AXI_PORT still work. UsedefaultPort()for the serve target (operators already set LAVISH_AXI_PORT), or thread the same port into both serve and ensureServer.🔧 Fix: Validate file and align serve port on enable
3 warnings still open:
src/cli.js:665- On enable, when prior state has a differenthttpsPort, the old port is cleared before the newtailscale servesucceeds. If the new serve fails, state still records the old port as active while neither port proxies—lavish-axi tailnet --statusreports ON with deadtailnet_urls. Because serve configs are port-scoped and intentionally coexist, configure the new port first, then clear the prior port only after the new serve (and preferably state write/restart) succeeds; on new-serve failure, leave the prior port intact (or restore it).src/cli.js:623---offalways runstailscale serve … offwhenever the Tailscale binary exists, including whentailnet.jsonis absent (already off). A non-zero serve status then throws instead of returning the "already off" payload—so with Tailscale stopped/unavailable, idempotent--offfails. Hard-fail serve cleanup only when state is present; when state is null, return already-off (optional best-effort serve clear that ignores failure).src/cli.js:695- If writingtailnet.jsonorensureServer({ forceRestart: true })fails after serve is live, the catch deletes state and runsbuildServeOffArgsbut ignores spawn status. A failed rollback leave serve proxying the unauthenticated loopback server while status reports off (and a server started under the brief state may still have the MagicDNS Host allowlist until another restart). Check serve-off status and surface a combined error with manualtailscale serve --https=<port> offremediation when rollback cannot complete.🔧 Fix: Fix tailnet enable, off, and rollback edges
1 warning still open:
src/cli.js:678- Enable rollback after a successful new-port serve does not restore prior state. Concrete path: tailnet is ON on httpsPort A (state + serve live); operator changes LAVISH_AXI_TAILNET_HTTPS_PORT to B and re-runs enable; serve on B succeeds; writeFileSync overwrites tailnet.json with B; ensureServer({forceRestart:true}) throws; catch always rmSyncs state and only runs serve-off for B. Prior serve on A was intentionally left up until post-success cleanup (line 694+), so A keeps proxying the unauthenticated loopback server while status/readTailnetState report OFF. Same-port re-enable is also over-destructive: a failed restart deletes state and clears the working serve. On catch, restore prior into tailnet.json when prior is non-null (else delete); serve-off the new httpsPort only when prior is null or prior.httpsPort !== httpsPort; when prior is restored after a failed forceRestart, forceRestart again so the Host allowlist matches restored state.🔧 Fix: Restore prior tailnet state on enable rollback
✅ Re-checked - no issues remain.
✅ **Test** - passed
✅ No issues found.
node --test test/tailnet.test.js(7 pure unit tests for DNS parse, serve args, state, URL rewrite, allowlist)node --test --test-name-pattern "isSameOriginRequest matches host" test/server.test.js(TLS-terminator Origin vs Host)Manual E2E with isolatedLAVISH_AXI_STATE_DIR+LAVISH_AXI_PORT=43991:node dist/cli.mjs tailnet --status(off)node dist/cli.mjs open <demo.html>thennode dist/cli.mjs tailnet <demo.html>enable on live MagicDNSdevcube.tail9a8fe8.ts.netcurl https://devcube.tail9a8fe8.ts.net:8443/session/<key>→ 200 Lavish chrome;/healthok; Hostevil.example→ 403; MagicDNS Host on loopback → 200node dist/cli.mjs openwhile enabled returnstailnet_url; headless Chrome screenshot of MagicDNS session URLnode dist/cli.mjs tailnet --offthen recheck: MagicDNS Host 403, :8443 refused, local session still 200;stop✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.