| name | tailscale-management |
| description | Tailscale VPN daemon management, CLI integration, authentication, and networking for the tailrelay container. Use when working with Tailscale configuration, login flows, device authentication, MagicDNS, HTTPS certificates, or network connectivity issues. |
| reviewed_at | 0fe9352 |
Tailscale Management
Overview
tailrelay runs Tailscale in userspace networking mode inside a Docker container. The daemon (tailscaled) is started by start.sh and managed throughout the container lifecycle. The Go Web UI wraps the Tailscale CLI for status/login/networking operations.
Architecture
start.sh (entrypoint)
โโโ tailscaled --tun=userspace-networking
โโโ SOCKS5 proxy on localhost:1055
โโโ Health check on :9002/healthz
โโโ Metrics on :9002/metrics
webui/internal/tailscale/
โโโ client.go # CLI wrapper (tailscale status, tailscale up, etc.)
โโโ status.go # Status parsing structs
โโโ networking.go # Prefs/NetworkingSummary โ networking preferences via `tailscale set`
โโโ cache.go # StatusCache โ background poller (15s interval)
Daemon Startup
In start.sh, tailscaled starts with:
tailscaled --state="$TAILSCALED_STATE" \
--socket="$TAILSCALED_SOCKET" \
--tun=userspace-networking \
--socks5-server=localhost:1055 \
> /var/log/tailscaled.log 2>&1 &
Key flags:
--tun=userspace-networking โ No NET_ADMIN capability or /dev/net/tun required
--socks5-server=localhost:1055 โ SOCKS5 proxy for outbound connections
- State persisted at
$TS_STATE_DIR/tailscaled.state
Environment Variables
| Variable | Default | Purpose |
|---|
TS_HOSTNAME | (required) | Tailscale machine name |
TS_STATE_DIR | /var/lib/tailscale/ | State directory (persisted via Docker volume) |
TS_EXTRA_FLAGS | (empty) | Additional flags passed to tailscale up |
TS_AUTH_ONCE | true | Only authenticate once (don't re-auth on restart) |
TS_ENABLE_METRICS | true | Expose metrics at :9002/metrics |
TS_ENABLE_HEALTH_CHECK | true | Expose health at :9002/healthz |
Web UI Integration
The Go package at webui/internal/tailscale/ wraps CLI commands:
- Status:
tailscale status --json โ parsed into Go structs
- Login: LocalAPI
login-interactive, or the CLI tailscale login/up --authkey= when a custom control server is configured โ returns auth URL
- Device list: Extracted from status JSON
- Network auth: Requests from
100.x.y.z IPs are auto-authenticated
StatusCache
cache.go provides a StatusCache that polls IsConnected every 15 seconds in the background. This avoids hammering the Tailscale CLI on every request.
cache := tailscale.NewStatusCache(client)
cache.Start(ctx)
if cache.IsReady() {
}
Use StatusCache.IsReady() wherever you need to gate on Tailscale connectivity (e.g. TLS cert provisioning, auth middleware).
Authentication Flow
- User visits Web UI โ checks if request is from Tailscale IP
- If not on Tailscale network โ shows login page with Tailscale auth link
- Login page polls
/api/tailscale/status until device is connected
- Once connected โ session authenticated automatically
Custom Control Server (Headscale) (controlserver.go)
Config.Tailscale.ControlServer (persisted in webui.yaml) lets the Web UI
authenticate against a self-hosted Headscale
instance instead of Tailscale's default control plane, set via the Control
Server field on the Tailscale page's connection status card.
tailscale.ValidateControlServerURL() requires an http/https scheme
and non-empty host; an empty string is always valid and means "use
Tailscale's default control plane".
Client.Login(controlServer string): when controlServer is empty, this
behaves as before (LocalAPI login-interactive). When set, --login-server
isn't accepted by the LocalAPI endpoint or by tailscale set โ only by the
login/up CLI subcommands โ so it instead runs
tailscale login --login-server=<url> via the CLI in the background, then
polls /localapi/v0/status for AuthURL exactly like the LocalAPI path.
Client.LoginWithAuthKey(key, controlServer string): appends
--login-server=<url> to the existing tailscale up --authkey=<key> call.
Client.UpWithHostname(hostname, controlServer string): runs tailscale up --hostname=<name> --reset. --reset resets any unspecified flag
(including ControlURL) to Tailscale's default control plane, so
--login-server=<url> is appended whenever controlServer is set โ
otherwise every hostname change would silently detach the node from its
Headscale server.
handlers.TailscaleHandler.Login/LoginWithKey/ChangeHostname read the
persisted control server (guarded by a sync.Mutex on the handler, since
*config.Config has no locking of its own) and pass it through
automatically โ the frontend doesn't need to resend it with every request.
GET /api/tailscale/control-server / POST /api/tailscale/control-server/update
read and persist the setting via config.Save.
- Changing the control server has no effect on a device that's already
registered until it's logged out and re-authenticated โ Tailscale binds a
node identity to whichever control server it first authenticated with.
- The persisted setting is only used to build
--login-server=<url> for
a future login/connect โ it does not drive any runtime behaviour (Funnel
visibility, serve relay scheme). Those are derived live from tailscaled's
actual ControlURL preference instead (see Prefs.IsCustomControlServer
below and serve.Manager.WebListenerScheme in the serve skill), so they
stay correct even if a device was authenticated against a custom control
server outside the Web UI (CLI login, restored state) without this
setting ever being saved.
Live Custom-Control-Server Detection (networking.go)
Prefs.ControlURL (added to the /localapi/v0/prefs decode) holds the
control plane tailscaled is currently authenticated against.
Prefs.IsCustomControlServer() compares it against the well-known Tailscale
default (https://controlplane.tailscale.com, ipn.DefaultControlURL
upstream); Client.IsCustomControlServer() wraps GetPrefs() for callers
that only need the boolean. GetStatusSummary() populates
StatusSummary.IsCustomControlServer from this on every /api/tailscale/status
poll โ the frontend's hideFunnel derived store
(webui/frontend/src/lib/stores/app.js) reads this field rather than the
persisted control-server setting, and serve.Manager.WebListenerScheme
(see the serve skill) uses the same live signal to pick --https vs
--http. If the LocalAPI prefs lookup fails (e.g. daemon still starting),
both consumers fail safe: the status summary omits the field (false) and the
serve Manager falls back to its persisted flag rather than erroring.
Networking Preferences (networking.go)
Unlike tailscale up (which requires re-specifying the complete set of
desired flags on every call, or --reset), tailscale set only changes the
flags explicitly passed โ this is what the Web UI's Networking section on
the Tailscale page uses to toggle exit-node advertisement, subnet routes,
accept-routes, exit-node selection, and SSH without disturbing other
preferences (like the hostname set elsewhere via UpWithHostname).
Client.GetPrefs() reads /localapi/v0/prefs (tailscaled's full ipn.Prefs,
trimmed to the fields this app cares about: RouteAll, ExitNodeIP,
ExitNodeAllowLANAccess, RunSSH, AdvertiseRoutes).
Client.GetNetworkingSummary() derives a simplified NetworkingSummary.
Tailscale has no separate preference for exit-node advertisement โ it
is implemented as the pair of default routes 0.0.0.0/0 and ::/0 inside
AdvertiseRoutes. summarizeNetworking() detects that pair to set
AdvertiseExitNode and excludes those two CIDRs from the AdvertiseRoutes
field returned to the frontend (which only shows custom subnet routes).
Client.SetNetworking(opts NetworkingOptions) builds one tailscale set
invocation from whichever NetworkingOptions pointer fields are non-nil.
The corresponding HTTP handler (handlers/tailscale.go's
APINetworking/UpdateNetworking) validates advertise_routes entries
via net/netip.ParsePrefix, rejecting host-bit-set CIDRs and rejecting
0.0.0.0/0/::/0 directly (those must go through advertise_exit_node
instead, keeping a single source of truth per UI control).
See the serve skill (.agents/skills/serve/SKILL.md) for tailscale serve/
funnel relay management, which is a distinct concern from these node-level
networking preferences.
Machine Name
Client.UpWithHostname uses tailscale set --hostname=<name>. Unlike
tailscale up --reset, set changes only the machine name and preserves the
active control server and all other node preferences.
HTTPS Certificates
Tailscale provides automatic TLS certificates for *.ts.net domains via tailscale cert:
- Must be enabled in Tailscale Admin Console โ HTTPS Certificates
- MagicDNS must be enabled (default for tailnets created after Oct 2022)
- Used by
tailscale serve for HTTPS relay termination
Troubleshooting
Daemon won't start
docker logs tailrelay | grep tailscaled
cat /var/log/tailscaled.log
Device not showing in Tailnet
docker exec tailrelay tailscale status
docker exec tailrelay tailscale up --hostname=$TS_HOSTNAME
Health/metrics endpoints not responding
curl http://<host>:9002/healthz
curl http://<host>:9002/metrics
State persistence
- Volume mount
/var/lib/tailscale to persist login state across restarts
- Start9 removes files on reboot โ back up
/home/start9/tailscale