Docs / Developer / Remote access: Tailscale and LAN
Remote access: Tailscale and LAN
Remote access lets the studio's owner reach it from their own phone or another computer. It is off by
default, driven entirely from Settings > Remote access (never a terminal), and comes in two independent
modes that can each be on or off:
- Tailscale (
src/ui/remote/sidecar-manager.ts,sidecar/main.go): a private, encrypted link over
the owner's own tailnet. No router changes, no ports opened to the internet. - LAN (
src/ui/remote/lan-*.ts): a no-account path for devices on the same local network, gated by a
connect code the owner reads off-screen and a per-device approval the owner grants in the app.
Neither mode ever exposes the studio to the open internet, and every device is gated before it is served.
Containers and SSH forwarding
Running Bunzai in a container or on a headless machine does not require a browser inside that
environment. The bunzai ui startup receipt prints:
- the local Studio URL;
- one copyable SSH command with a forward for the Studio port; and
- when the isolated Lua/regex listener started, a second forward in that same command for its random
sandbox port.
Run the printed command on the computer with the browser, replace user@server, keep the tunnel open,
and browse to the printed http://localhost:<ui-port> address. Browsing to the server or container IP
is intentionally refused by the trusted listener's Host gate. The HTML shell can still arrive before
the first /api/settings request is refused, so the browser now turns that otherwise opaque 403 into
the same localhost and SSH instructions. It offers one button for the Studio and another deep link
that opens the forwarded Studio directly on Settings > Remote access. A third button copies the
one-port recovery command. The normal host-only API gates still decide whether those controls may
change anything.
A one-port tunnel is enough for ordinary Studio work. It is not enough for the Lua and regex test
benches because ADR-009 puts their workers on a second, ephemeral loopback origin. Forward both ports
from the startup receipt to use those benches remotely. Tailscale and LAN access serve the approved
Studio surface, but they do not turn the sandbox-only listener into a general remote endpoint.
This guidance does not broaden hostAllowed, expose a new listener, or weaken the existing token and
Origin checks. It makes the two-listener architecture visible at the point where a user needs it.
Two listeners, one trust boundary
The app binds two loopback listeners (src/ui/server-remote.ts, src/ui/server.ts):
- Trusted
127.0.0.1:<port>(default 8321): the local owner's own browser. Gated by a loopback
Host/Origin check plus a per-launch session token on state-changing requests
(checkApiRequest,src/ui/server-security.ts). - Untrusted
127.0.0.1:<private-port>: the ONLY thing that reaches it is the Tailscale sidecar, which
reverse-proxies already-owner-gated remote traffic here. Gated by a per-boot shared secret the sidecar
stamps on every forwarded request (checkRemoteApiRequest), plus the session token on writes.
The shared secret is a CSPRNG value generated once per launch (setupRemoteAccess,src/ui/server-remote.ts), passed to the sidecar via an environment variable, never argv (argv is
world-readable in the process table), and:
- the untrusted listener REQUIRES it (a local process or DNS-rebinding page has no secret, so it fails
closed); - the trusted listener REFUSES it on any path, before routing: a request carrying the sidecar secret
is by definition sidecar-proxied remote traffic and has no business at the local door
(createHandler,src/ui/server.ts).
Each Studio instance binds a separate ephemeral private loopback port and passes its actual address to its own helper. Its own per-boot secret authenticates that listener; another instance's secret fails. A failed private bind disables mesh access while local Studio and LAN remain available. A failed main-listener startup releases its private listener, and closing before saved settings become readable prevents a later automatic mesh restart. LAN retains port 8790 and reports an occupied-port failure rather than attaching to another app.
The Tailscale owner gate
The sidecar (sidecar/main.go) embeds Tailscale via tsnet and, for every remote request, calls WhoIs
on the caller's tailnet address and admits it only if it is the studio owner's own account:
- Identity is compared by numeric user id:
who.UserProfile.ID == Self.UserID(resolveOwnerID+newProxy). Login-name strings are not used for the decision (tsnet can return an empty login for the
owner's own node while the numeric id is populated). - Fail closed everywhere: an unresolved owner id (0) denies everyone; an unidentifiable caller (no
WhoIs) is denied; a caller whose node has no stable id is denied, because a device we cannot track or
kick is a device we will not serve. - Identity headers forwarded upstream (
X-Tailscale-User,X-Tailscale-Node) are taken fromWhoIs,
never from client-supplied headers, so a forged header cannot survive.
These properties are unit-tested against a fake resolver (sidecar/main_test.go).
The LAN gate: connect code + per-device approval
On a local network there is no tailnet identity, so LAN mode gates on two things a stranger cannot supply
(src/ui/remote/lan-host.ts, the security core):
- A connect code the owner reads off their screen and types on the other device. The code is drawn
from a CSPRNG with rejection sampling to remove modulo bias, hashed with argon2id
(Bun.password), and never sent to a connecting device. Wrong guesses are throttled per IP with
exponential backoff (never a global lockout, which would let one bad device lock the owner out). - Host approval. A device that enters the right code becomes pending and is served nothing until
the owner approves it in the app. Pending sessions are capped per IP and expire after 15 minutes, so
the code alone cannot flood the approval list or leave stale entries.
The LAN listener binds 0.0.0.0:8790 with a self-signed TLS cert (src/ui/remote/lan-server.ts) so
the code and traffic never cross the wire in the clear. The cert carries the host's LAN IP and loopback in
a subjectAltName (a CN-only cert triggers a harsher, sometimes un-bypassable browser error), and is
regenerated if the cached one no longer covers the current IP. The private key is written with chmod 600
(best effort; Windows uses ACLs). The owner verifies the shown sha256 fingerprint on first connect.
Session transport and bounded admission
The approval cookie is Secure; HttpOnly; SameSite=Lax; Path=/. An HTTPS listener alone does not
protect a bearer cookie from plaintext requests to another port on the same host. Browsers that
cannot retain a Secure cookie must fail closed; certificate compatibility is not a reason to remove it.
The login route caps JSON at 1,024 bytes and code input at 32 characters. Verification reserves admission
before awaiting Argon2: one comparison per IP, at most four globally, without an unbounded queue.
Attempt records cap at 1,024 and sessions at 256; expired pending sessions are swept before admission.
Reset immediately invalidates credentials and sessions, and only the current generation can publish
a hash or issue a session. Disabling LAN access also invalidates any startup still in flight.
Host-only surface
Whether a device tailed in over Tailscale or was approved on the LAN, it may USE the studio but must never
reach management or host control. isHostOnlyRoute (src/ui/server-remote.ts) refuses, for any
remote/LAN-served handler, every /api/remote/* route (turn access on/off, kick devices), /api/open
(open a browser on the host), /api/shutdown, /api/restart, and settings writes. Those belong to the
local owner alone.
/api/export-folder and its file-upload route are host-only too: they write plaintext exports to the
host's disk. Remote and LAN clients keep browser downloads on their own device instead. Each host export
uses a new bounded-name folder, accepts files separately under the archive byte cap, never overwrites a
completed file, and removes only a failed upload's own exclusive file. Cancellation before opening a file
cannot be reported as a successful empty export. Handler tests exercise the local token and remote refusal.
Kicking a device
The owner can remove any connected device from the host. Over Tailscale, the app writes a kick command to
the sidecar's stdin; the sidecar blocklists that node's stable id and denies it on the next request, even
though it is the owner's own account (deviceTracker, sidecar/main.go). On LAN, the session is dropped.
Where the sidecar binary comes from
Supported packaged desktop and CLI builds include their matching compiled Go helper inside the app.scripts/sidecar-bake.ts selects exactly the compile target's asset from SIDECAR_TARGETS; a missing
asset fails the build. The generated file-loader module is restored to a null placeholder after
compilation, so a source checkout does not claim to contain an embedded helper.
Before starting the Studio, the desktop and CLI entrypoints prepare the embedded helper without a
network request or process start (src/ui/remote/bundled-sidecar.ts). They check its target, size and
SHA-256, then atomically extract it into the product's per-user support directory atsidecar/bundled/<sha256>/sidecar (sidecar.exe on Windows). Existing cached bytes must match before
reuse; a damaged cache is atomically repaired from the already verified embedded bytes. Each digest
has its own path, so an app update does not overwrite an older running helper.
Preparation does not enable remote access or change its saved setting. A failed preparation leaves the
local Studio usable and does not hand an unverified path to the sidecar manager.
Packaged apps use only the verified path returned by preparation. They do not search the source
tree, neighboring sidecar/ folders, PATH, or the optional download cache. This remains true if
preparation fails: an extracted archive beside the app cannot silently supply executable code.
If preparation cannot complete, the panel gives repair guidance and LAN remains available. The
packaged helper-download endpoint is refused because a separate download is not its helper authority.
Source runs retain these developer locations in order (src/ui/server-remote.ts):
- the repository's own
sidecar/folder, wherebun sidecar/build.tswrites it - the from-source path; - a
sidecar/folder beside the running executable, for anyone who places one there deliberately; - the aux-package cache under the per-user support directory, where a requested download installs it.
None present means the mesh path reports unavailable, its own RemotePhase, distinct from both off
and error. off is a switch the owner can flip and error invites a retry; a build with no helper can do
neither, so the panel states that up front and offers no Enable control rather than spawning a path that is
not there. LAN mode is unaffected and needs no helper at all.
The source-location tests remain in src/ui/server-remote.test.ts. Packaged selection and failed
preparation refusals are covered through real startup wiring with mocked spawn insrc/ui/server-remote-selection.test.ts; no arbitrary helper is executed by those regressions.
The aux package: fetching the helper on request
Separate helper release assets remain available for source checkouts, older apps and recovery. Current
supported app builds already carry the helper and do not offer this extra download. The source-only
host action is a download-and-execute path and is built as one
(src/ui/remote/aux-download.ts, aux-install.ts):
New normal releases publish only the app executables and checksums, not separate helper files. The
committed recovery pins continue to name the older v0.1.53 assets. Those published files are retained;
removing them or retargeting their pins to a new app-only release would break older installations.
A SHA-256 pin is the integrity control, and it is COMMITTED (
src/ui/remote/sidecar-pins.ts), pinned
like a dependency.bun scripts/pin-sidecar.ts <tag>reads that release's publishedSHA256SUMS,
re-downloads each asset to confirm it matches, and rewrites the module; the result is reviewed and
committed like any lockfile bump. An empty table offers no download.Computing the pin during the build was the earlier design and it quietly split the product in two: only
a CI-built binary carried a hash, so the download worked for packaged users and silently did not for
anyone running from source. Committing it makes the answer identical everywhere. It works because the
helper is not tied to an app version - it is a separate process speaking one small stdio protocol, so
one known-good release serves indefinitely. The coupling is to the protocol, not the version number,
which is also why there is no staleness signal.Verification happens on bytes in memory, before anything is written to the path the spawner reads, so
a tampered or truncated download cannot leave a runnable file behind. The install itself is a rename from
a sibling staging file, so a torn write never becomes the final binary. On POSIX the helper is0o700.HTTPS only, and the hops are inspected. The first must be
github.com; a redirect may only land on
GitHub's asset family, which is required rather than optional because release downloads really do redirect
to a signed, time-limited host. A relativeLocationis resolved against the current URL so it cannot
smuggle in a host change.The size cap is applied while reading, not after buffering, so a response with no or an understated
Content-Lengthcannot cost the memory the cap exists to protect.Host-only.
/api/remote/helper/*sits under the/api/remote/prefix, so a tailed-in or LAN-approved
guest cannot make the host machine download and install an executable.
The host allowlist is defence in depth, not the guarantee. If GitHub renames its asset host, this loses
availability and keeps integrity, which is the correct way round.
The optionally downloaded helper is not re-hashed before each spawn. It lives in the owner's own per-account support
directory, so anyone able to rewrite it can equally rewrite Bunzai.exe; re-verifying would defend
against an attacker who already holds the account. There is deliberately no "your helper is out of date"
signal either: the pinned hash changes on every release whether the helper changed or not, so a staleness
warning derived from it would fire after every app update about a helper that works. The panel reports
helper availability without naming a release: a source, sibling or bundled helper may take precedence
over the downloaded cache, so its download marker does not prove which helper is selected. Fetching
again is offered only when this build has a published pin. A newly installed local helper becomes
available on the next status read without enabling mesh access. Downloading also leaves mesh off;
the refreshed status exposes a separate explicit Enable action.
The committed Bunzai download pin table names the published v0.1.53 Windows x64 and Linux x64/ARM64 helpers. That release has no macOS helper asset, so macOS offers no download. A locally built helper can use the product-specific support directory without pretending to be a published release.
Sidecar lifecycle
The sidecar is a background process that speaks only over stdio pipes (status events out, kick commands
in). It watches its stdin: when the parent app dies, stdin hits EOF and the sidecar exits, so a crashed app
can exit without a process-name sweep. The manager never force-kills programs by image name; it stops
only its retained child handle. Late output from a stopped child cannot restore an active state.
LAN startup is coalesced and generation-checked, so a listener arriving after Off is stopped immediately.
The binary is built as a Windows GUI-subsystem executable (bun sidecar/build.ts, -H=windowsgui) so
that spawning it from the window-less packaged app never pops a console window; the parent-provided pipes
still work because they are handles, independent of any console. Spawns also pass windowsHide as a second
layer.
What is proven
- The Tailscale owner gate and its fail-closed cases are unit-tested (
sidecar/main_test.go). - The LAN code format, per-IP throttle, pending cap, and TTL are unit-tested (
src/ui/remote/lan-host.test.ts). - Capped login bodies, Secure-cookie issuance, hash concurrency, reset races and startup revocation have
isolated handler/lifecycle regressions. These October 30 changes have not been reverified in a browser,
on a real LAN or in the packaged host. - The trusted-port secret refusal and the host-only route block are tested at the handler
(src/ui/server-remote.test.ts), including that a guest cannot trigger a helper download. - The three binary locations, the
unavailablephase, and the refusal to spawn or to persist an "on" that
cannot happen are unit-tested (src/ui/server-remote.test.ts,src/ui/remote/sidecar-manager.test.ts).
The packaged path resolution was also confirmed against a real compiled executable. - Every aux-download refusal is unit-tested (
src/ui/remote/aux-download.test.ts): pin mismatch, truncation,
a non-GitHub or plain-http hop, a lookalike host, a relative redirect, redirect loops, and a body that
exceeds the cap while streaming. - The checksum reader that writes the committed pins is unit-tested (
scripts/sidecar-pin-format.test.ts):
it takes only the helper assets, ignores every other binary in the file, and refuses a duplicate digest
rather than choosing between two answers. - That the release builds and uploads a helper for every pinned platform, and that every committed pin names
an asset the release actually publishes, is pinned byscripts/ci-sidecar.test.ts. Those assertions cover
the workflow's SHAPE; that the Go cross-builds themselves succeed is proven only by a real release run. - The full LAN path (code -> cookie -> pending -> host approval -> served; host-only routes refused to an
approved device) has been verified live end-to-end at the listener boundary, and the Tailscale path has
been verified live from a phone reaching the studio.
The October 31 coexistence changes have isolated real-handler HTTP and lifecycle regressions. This pass does not claim a real Tailscale sign-in, physical-device LAN journey or new cross-platform packaged verification.
Local-access certificate dependency and failures
Local network access creates its self-signed HTTPS certificate with installed OpenSSL. The host first
uses OpenSSL on PATH. On Windows it also checks the standard Git for Windows installation's usr/bin
and mingw64/bin executables. No certificate command downloads or installs an executable. If none is
available, the settings card explains how to install OpenSSL or Git for Windows and permits retry.
Certificate-generation and fingerprint failures remain failures; the UI does not claim local access is on.
Enable, disable, approval, denial and removal errors appear in the card. Host-only management, connect
codes and explicit device approval remain required.
The isolated lan-tls.test.ts regression starts a real ephemeral HTTPS listener, validates its IP SAN and
fingerprint, confirms an unapproved request cannot reach the app, and stops the listener afterward.
This is source-level listener proof, not verification of an already installed binary or a physical device.
The v0.1.53 helper pins were verified against the published SHA256SUMS and downloaded bytes.
Updating source pins does not retrofit an already installed binary.