feat: add optional Tailscale sidecar for secure private access#210
feat: add optional Tailscale sidecar for secure private access#210QuantDeveloperUSA wants to merge 12 commits into
Conversation
Activated by setting TS_AUTHKEY (and optionally TS_HOSTNAME). When set, starts tailscaled in userspace-networking mode, authenticates, and exposes the gateway via Tailscale Serve (HTTPS:443). Also syncs allowedOrigins to include Railway public domain and Tailscale MagicDNS URL for Control UI WebSocket connections.
Browser-cached Basic auth credentials (from requireDashboardAuth) were being forwarded to the gateway in WebSocket upgrades. Since attachGatewayAuthHeader only injected the Bearer token when no auth header was present, the gateway received Authorization: Basic ... instead of Authorization: Bearer TOKEN, causing 'gateway token mismatch' on all WebSocket connections. Fix: unconditionally set the Bearer token on every proxied WebSocket upgrade. Basic auth has already been validated upstream by requireDashboardAuth.
The gateway speaks WebSocket protocol only, so fetch() to port 18789 always throws (gateway closes the connection after receiving a plain HTTP GET). waitForGatewayReady never saw a truthy Response and always timed out after 20s, causing 'Gateway did not become ready in time' on every cold start. Fix: use a TCP socket connect probe (same approach as probeGateway) which succeeds as soon as the gateway opens its port, regardless of protocol.
Tailscale HTTPS (port 443) was proxying directly to the gateway port (18789), bypassing the wrapper (port 8080) that injects the Bearer token via attachGatewayAuthHeader(). This caused 'pairing required' errors when connecting via Tailscale HTTPS.
…stname and IP addresses
syncAllowedOrigins now adds both https:// (for tailscale serve) and http://<host>:PORT (for direct MagicDNS access) variants for: - FQDN (e.g. athena-1.marlin-mirach.ts.net) - Short hostname (e.g. athena-1) - IPv4 and IPv6 Tailscale IPs Also log the full origins list and stop silently swallowing tailscale status errors.
…AllowedOrigins Tailscale serve only provides a valid TLS cert for the machine FQDN (e.g. athena.marlin-mirach.ts.net). Adding https:// variants for the bare short hostname (athena) or raw IP addresses (100.x, fd7a:...) would cause TLS verification failures since no cert covers them. Replace with HTTP+port only for those entries. The FQDN keeps its https:// entry (served by tailscale serve on port 443).
There was a problem hiding this comment.
Pull request overview
Adds optional Tailscale sidecar behavior to enable private tailnet access to the Railway-hosted OpenClaw wrapper, and adjusts gateway readiness/auth handling to better support proxying and WebSocket behavior.
Changes:
- Add Tailscale bootstrap +
tailscale serveenablement in the wrapper (optional viaTS_AUTHKEY/TS_HOSTNAME). - Replace gateway readiness check with a TCP probe (gateway speaks WebSocket).
- Sync gateway Control UI
allowedOrigins, and always overwrite proxiedAuthorizationwith the gateway Bearer token.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 9 comments.
| File | Description |
|---|---|
Dockerfile |
Installs Tailscale in the runtime image; bumps pinned OpenClaw ref. |
src/server.js |
Implements Tailscale boot/serve flow, TCP readiness probe, allowed-origins sync, and auth header overwrite. |
README.md |
Documents optional Tailscale setup and updates template description/credits. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } | ||
|
|
||
| async function syncAllowedOrigins() { | ||
| const origins = [`http://localhost:${INTERNAL_GATEWAY_PORT}`, `http://127.0.0.1:${INTERNAL_GATEWAY_PORT}`]; |
There was a problem hiding this comment.
syncAllowedOrigins() seeds localhost origins using INTERNAL_GATEWAY_PORT, but the browser origin will be the wrapper’s public PORT (e.g. http://localhost:8080). This will likely block the Control UI locally (and in any environment where RAILWAY_PUBLIC_DOMAIN/TS origins aren’t present) because the gateway will reject the Origin header. Use PORT for wrapper origins (http://localhost:${PORT}, http://127.0.0.1:${PORT}) and only include INTERNAL_GATEWAY_PORT if you explicitly expect direct browser access to the internal gateway port.
| const origins = [`http://localhost:${INTERNAL_GATEWAY_PORT}`, `http://127.0.0.1:${INTERNAL_GATEWAY_PORT}`]; | |
| const origins = [`http://localhost:${PORT}`, `http://127.0.0.1:${PORT}`]; |
| const status = await runCmd("tailscale", ["status", "--json"]); | ||
| console.log(`[wrapper] tailscale status exit=${status.code} output=${debugSnippet(status.output)}`); | ||
|
|
||
| tailscaleUpDone = true; | ||
| console.log(`[wrapper] Tailscale authenticated (hostname=${hostname})`); | ||
| return { ok: true }; | ||
| } catch (err) { | ||
| console.warn(`[wrapper] Tailscale bootstrap failed: ${String(err)}`); | ||
| return { ok: false, reason: String(err) }; |
There was a problem hiding this comment.
ensureTailscaleBoot(): a failure to run/parse tailscale status --json is treated as a bootstrap failure even if tailscale up already succeeded. That can cause repeated tailscale up retries on subsequent calls and potentially multiple tailscaled instances. Consider making the status call best-effort (log but don’t fail), or at least gate JSON.parse on status.code === 0.
| const status = await runCmd("tailscale", ["status", "--json"]); | ||
| console.log(`[wrapper] tailscale status after serve exit=${status.code} output=${debugSnippet(status.output)}`); | ||
| const ts = JSON.parse(status.output); | ||
| const dns = (ts?.Self?.DNSName || "").replace(/\.$/, ""); | ||
| if (dns && isConfigured()) { | ||
| await runCmd(OPENCLAW_NODE, clawArgs(["config", "set", "gateway.auth.allowTailscale", "true"])); | ||
| console.log(`[wrapper] Tailscale up: https://${dns}`); | ||
| } | ||
|
|
||
| tailscaleServeDone = true; | ||
| return { ok: true }; | ||
| } catch (err) { | ||
| console.warn(`[wrapper] Tailscale serve failed: ${String(err)}`); | ||
| return { ok: false, reason: String(err) }; | ||
| } |
There was a problem hiding this comment.
ensureTailscaleServe(): after successfully running tailscale serve, the subsequent tailscale status --json + JSON.parse can throw (nonzero exit, non-JSON output) and the function will return ok:false without setting tailscaleServeDone. This can lead to repeated tailscale serve attempts and noisy logs even though serve is already configured. Treat the status fetch/parse as best-effort, or check status.code before parsing and still set tailscaleServeDone when serve succeeded.
| let tailscaleUpDone = false; | ||
| let tailscaleServeDone = false; | ||
|
|
||
| async function ensureTailscaleBoot() { | ||
| if (!process.env.TS_AUTHKEY) { | ||
| console.log("[wrapper] Tailscale bootstrap skipped: TS_AUTHKEY not set"); | ||
| return { ok: false, reason: "TS_AUTHKEY not set" }; | ||
| } | ||
| if (tailscaleUpDone) return { ok: true }; | ||
|
|
||
| try { | ||
| const hostname = process.env.TS_HOSTNAME?.trim() || "openclaw-railway"; | ||
| console.log(`[wrapper] Tailscale bootstrap: auth=${debugPrefix(process.env.TS_AUTHKEY)} hostname=${debugPrefix(hostname)}`); | ||
| childProcess.spawn("tailscaled", ["--tun=userspace-networking", "--statedir=/data/tailscale"], { stdio: "ignore" }); | ||
| await sleep(3000); |
There was a problem hiding this comment.
ensureTailscaleBoot() is not concurrency-safe: the guard is only tailscaleUpDone, so concurrent callers (e.g. the boot-time await ensureTailscaleBoot() and a request-triggered ensureGatewayRunning→ensureTailscaleServe) can both spawn tailscaled and race tailscale up. Consider adding a shared in-flight promise (like gatewayStarting) to serialize bootstrapping and ensure only one tailscaled process is started.
| console.log(`[wrapper] TS_AUTHKEY: ${debugPrefix(process.env.TS_AUTHKEY)}`); | ||
| console.log(`[wrapper] TS_HOSTNAME: ${debugPrefix(process.env.TS_HOSTNAME)}`); |
There was a problem hiding this comment.
The wrapper logs a prefix/length for TS_AUTHKEY. Even partial auth keys can be sensitive and may be retained in Railway logs; consider logging only whether the variable is set (or redacting the entire value) to avoid leaking credentials into logs.
| console.log(`[wrapper] TS_AUTHKEY: ${debugPrefix(process.env.TS_AUTHKEY)}`); | |
| console.log(`[wrapper] TS_HOSTNAME: ${debugPrefix(process.env.TS_HOSTNAME)}`); | |
| console.log(`[wrapper] TS_AUTHKEY: ${process.env.TS_AUTHKEY ? "(set)" : "(missing)"}`); | |
| console.log(`[wrapper] TS_HOSTNAME: ${process.env.TS_HOSTNAME ? "(set)" : "(missing)"}`); |
| tini \ | ||
| python3 \ | ||
| python3-venv \ | ||
| && curl -fsSL https://tailscale.com/install.sh | sh \ |
There was a problem hiding this comment.
This installs Tailscale via curl ... | sh, which is a supply-chain risk and makes builds non-reproducible (script contents can change). Prefer using the official apt repo with pinned package versions (or verifying the script checksum/signature) so the image build is deterministic and safer.
| tini \ | |
| python3 \ | |
| python3-venv \ | |
| && curl -fsSL https://tailscale.com/install.sh | sh \ | |
| apt-transport-https \ | |
| tini \ | |
| python3 \ | |
| python3-venv \ | |
| && curl -fsSL https://pkgs.tailscale.com/stable/debian/bookworm.noarmor.gpg -o /usr/share/keyrings/tailscale-archive-keyring.gpg \ | |
| && curl -fsSL https://pkgs.tailscale.com/stable/debian/bookworm.tailscale-keyring.list -o /etc/apt/sources.list.d/tailscale.list \ | |
| && apt-get update \ | |
| && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends tailscale \ |
|
|
||
| Tailscale (optional): | ||
| - `TS_AUTHKEY` — a Tailscale auth key ([generate one here](https://login.tailscale.com/admin/settings/keys)). **Use a reusable key** so the node survives redeploys. | ||
| - `TS_HOSTNAME` — the machine name on your tailnet (e.g. `openclaw`). Defaults to the container hostname if not set. |
There was a problem hiding this comment.
Docs mismatch: code defaults TS_HOSTNAME to "openclaw-railway" (src/server.js), not the container hostname. Update the README to match the actual default, or update the code to use the container hostname (e.g. os.hostname()) if that’s the intended behavior.
| - `TS_HOSTNAME` — the machine name on your tailnet (e.g. `openclaw`). Defaults to the container hostname if not set. | |
| - `TS_HOSTNAME` — the machine name on your tailnet (e.g. `openclaw`). Defaults to `openclaw-railway` if not set. |
| https://<TS_HOSTNAME>.<tailnet>.ts.net | ||
| ``` | ||
|
|
||
| The Tailscale URL does **not** require HTTP Basic auth — access is controlled by your tailnet ACLs. The wrapper still injects the gateway Bearer token automatically. |
There was a problem hiding this comment.
README claims “The Tailscale URL does not require HTTP Basic auth”, but the wrapper’s requireDashboardAuth enforces Basic auth for all routes (except /healthz, /setup/healthz, /hooks) regardless of whether traffic comes via Tailscale. Either update the documentation to state Basic auth still applies, or add logic to bypass Basic auth for authenticated Tailscale connections (e.g. based on Tailscale Serve identity headers).
| The Tailscale URL does **not** require HTTP Basic auth — access is controlled by your tailnet ACLs. The wrapper still injects the gateway Bearer token automatically. | |
| The Tailscale URL is still protected by the wrapper's HTTP Basic auth. Tailscale provides private network access controlled by your tailnet ACLs, and the wrapper still injects the gateway Bearer token automatically. |
| async function syncAllowedOrigins() { | ||
| const origins = [`http://localhost:${INTERNAL_GATEWAY_PORT}`, `http://127.0.0.1:${INTERNAL_GATEWAY_PORT}`]; | ||
| if (process.env.RAILWAY_PUBLIC_DOMAIN) origins.push(`https://${process.env.RAILWAY_PUBLIC_DOMAIN}`); | ||
| try { | ||
| const ts = JSON.parse((await runCmd("tailscale", ["status", "--json"])).output); | ||
| const dns = (ts?.Self?.DNSName || "").replace(/\.$/, ""); | ||
| if (dns) { | ||
| // HTTPS only for the FQDN — tailscale serve provides a valid cert for it. | ||
| origins.push(`https://${dns}`); | ||
| origins.push(`http://${dns}:${PORT}`); | ||
| // Short hostname (MagicDNS alias, e.g. "athena"). HTTP only — no TLS cert for bare hostnames. | ||
| const shortName = dns.split(".")[0]; | ||
| if (shortName) { | ||
| origins.push(`http://${shortName}:${PORT}`); | ||
| } | ||
| } | ||
| // Tailscale IPs. HTTP only — no TLS cert for raw IP addresses. | ||
| for (const ip of ts?.Self?.TailscaleIPs || []) { | ||
| const bracket = ip.includes(":"); | ||
| origins.push(bracket ? `http://[${ip}]:${PORT}` : `http://${ip}:${PORT}`); | ||
| } | ||
| } catch (err) { | ||
| console.warn(`[wrapper] syncAllowedOrigins: tailscale status failed: ${String(err)}`); | ||
| } | ||
| console.log(`[wrapper] syncAllowedOrigins: ${JSON.stringify(origins)}`); | ||
| await runCmd(OPENCLAW_NODE, clawArgs(["config", "set", "--json", "gateway.controlUi.allowedOrigins", JSON.stringify(origins)])).catch(() => {}); |
There was a problem hiding this comment.
New behavior updates gateway.controlUi.allowedOrigins (syncAllowedOrigins), but there’s no regression test analogous to test/trusted-proxies-config.test.js. Given the repo’s pattern of source-level tests, consider adding a small test that asserts the server writes gateway.controlUi.allowedOrigins so future refactors don’t drop it silently.
| async function syncAllowedOrigins() { | |
| const origins = [`http://localhost:${INTERNAL_GATEWAY_PORT}`, `http://127.0.0.1:${INTERNAL_GATEWAY_PORT}`]; | |
| if (process.env.RAILWAY_PUBLIC_DOMAIN) origins.push(`https://${process.env.RAILWAY_PUBLIC_DOMAIN}`); | |
| try { | |
| const ts = JSON.parse((await runCmd("tailscale", ["status", "--json"])).output); | |
| const dns = (ts?.Self?.DNSName || "").replace(/\.$/, ""); | |
| if (dns) { | |
| // HTTPS only for the FQDN — tailscale serve provides a valid cert for it. | |
| origins.push(`https://${dns}`); | |
| origins.push(`http://${dns}:${PORT}`); | |
| // Short hostname (MagicDNS alias, e.g. "athena"). HTTP only — no TLS cert for bare hostnames. | |
| const shortName = dns.split(".")[0]; | |
| if (shortName) { | |
| origins.push(`http://${shortName}:${PORT}`); | |
| } | |
| } | |
| // Tailscale IPs. HTTP only — no TLS cert for raw IP addresses. | |
| for (const ip of ts?.Self?.TailscaleIPs || []) { | |
| const bracket = ip.includes(":"); | |
| origins.push(bracket ? `http://[${ip}]:${PORT}` : `http://${ip}:${PORT}`); | |
| } | |
| } catch (err) { | |
| console.warn(`[wrapper] syncAllowedOrigins: tailscale status failed: ${String(err)}`); | |
| } | |
| console.log(`[wrapper] syncAllowedOrigins: ${JSON.stringify(origins)}`); | |
| await runCmd(OPENCLAW_NODE, clawArgs(["config", "set", "--json", "gateway.controlUi.allowedOrigins", JSON.stringify(origins)])).catch(() => {}); | |
| export const CONTROL_UI_ALLOWED_ORIGINS_CONFIG_KEY = "gateway.controlUi.allowedOrigins"; | |
| export function buildAllowedOrigins( | |
| ts, | |
| { | |
| internalGatewayPort = INTERNAL_GATEWAY_PORT, | |
| port = PORT, | |
| railwayPublicDomain = process.env.RAILWAY_PUBLIC_DOMAIN, | |
| } = {}, | |
| ) { | |
| const origins = [`http://localhost:${internalGatewayPort}`, `http://127.0.0.1:${internalGatewayPort}`]; | |
| if (railwayPublicDomain) origins.push(`https://${railwayPublicDomain}`); | |
| const dns = (ts?.Self?.DNSName || "").replace(/\.$/, ""); | |
| if (dns) { | |
| // HTTPS only for the FQDN — tailscale serve provides a valid cert for it. | |
| origins.push(`https://${dns}`); | |
| origins.push(`http://${dns}:${port}`); | |
| // Short hostname (MagicDNS alias, e.g. "athena"). HTTP only — no TLS cert for bare hostnames. | |
| const shortName = dns.split(".")[0]; | |
| if (shortName) { | |
| origins.push(`http://${shortName}:${port}`); | |
| } | |
| } | |
| // Tailscale IPs. HTTP only — no TLS cert for raw IP addresses. | |
| for (const ip of ts?.Self?.TailscaleIPs || []) { | |
| const bracket = ip.includes(":"); | |
| origins.push(bracket ? `http://[${ip}]:${port}` : `http://${ip}:${port}`); | |
| } | |
| return origins; | |
| } | |
| async function syncAllowedOrigins() { | |
| let origins = buildAllowedOrigins(); | |
| try { | |
| const ts = JSON.parse((await runCmd("tailscale", ["status", "--json"])).output); | |
| origins = buildAllowedOrigins(ts); | |
| } catch (err) { | |
| console.warn(`[wrapper] syncAllowedOrigins: tailscale status failed: ${String(err)}`); | |
| } | |
| console.log(`[wrapper] syncAllowedOrigins: ${JSON.stringify(origins)}`); | |
| await runCmd( | |
| OPENCLAW_NODE, | |
| clawArgs(["config", "set", "--json", CONTROL_UI_ALLOWED_ORIGINS_CONFIG_KEY, JSON.stringify(origins)]), | |
| ).catch(() => {}); |
Summary
Adds optional Tailscale support so the Railway deployment can be securely accessed over a private tailnet without exposing credentials over the public internet.
How it works
Changes
Usage
Add two Railway Variables to your deployment:
\
TS_AUTHKEY=tskey-auth-... # from tailscale.com/admin/settings/keys
TS_HOSTNAME=my-openclaw # desired machine name in your tailnet
\\
Then access via \https://<TS_HOSTNAME>..ts.net/.
Tailscale is fully optional — if \TS_AUTHKEY\ is not set, all new code paths are skipped and the deployment behaves exactly as before.