-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat: add optional Tailscale sidecar for secure private access #210
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
90ffac9
cd88b03
ba03c77
4171381
2743522
bbf325a
489ea44
7067c8c
3a2b837
895dca8
fa5d230
e5e49cc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,4 +1,6 @@ | ||||||
| # OpenClaw Railway Template (1‑click deploy) | ||||||
| # OpenClaw Railway Template + Tailscale (1‑click deploy) | ||||||
|
|
||||||
| Fork of [vignesh07/clawdbot-railway-template](https://github.com/vignesh07/clawdbot-railway-template) that adds **optional Tailscale integration** so you can access your OpenClaw instance over a private tailnet instead of (or in addition to) the public Railway domain. | ||||||
|
|
||||||
| This repo packages **OpenClaw** for Railway with a small **/setup** web wizard so users can deploy and onboard **without running any commands**. | ||||||
|
|
||||||
|
|
@@ -9,13 +11,15 @@ This repo packages **OpenClaw** for Railway with a small **/setup** web wizard s | |||||
| - Persistent state via **Railway Volume** (so config/credentials/memory survive redeploys) | ||||||
| - One-click **Export backup** (so users can migrate off Railway later) | ||||||
| - **Import backup** from `/setup` (advanced recovery) | ||||||
| - **Optional Tailscale access** — expose the gateway on your private tailnet via HTTPS (e.g. `https://<hostname>.<tailnet>.ts.net`) | ||||||
|
|
||||||
| ## How it works (high level) | ||||||
|
|
||||||
| - The container runs a wrapper web server. | ||||||
| - The wrapper protects `/setup` (and the Control UI at `/openclaw`) with `SETUP_PASSWORD` using HTTP Basic auth. | ||||||
| - During setup, the wrapper runs `openclaw onboard --non-interactive ...` inside the container, writes state to the volume, and then starts the gateway. | ||||||
| - After setup, **`/` is OpenClaw**. The wrapper reverse-proxies all traffic (including WebSockets) to the local gateway process. | ||||||
| - If `TS_AUTHKEY` is set, the wrapper runs `tailscale up` at boot and `tailscale serve --https=443` after the gateway is ready, proxying Tailscale HTTPS traffic to the wrapper (which injects the gateway auth token automatically). | ||||||
|
|
||||||
| ## Railway deploy instructions (what you’ll publish as a Template) | ||||||
|
|
||||||
|
|
@@ -35,6 +39,10 @@ Recommended: | |||||
| Optional: | ||||||
| - `OPENCLAW_GATEWAY_TOKEN` — if not set, the wrapper generates one (not ideal). In a template, set it using a generated secret. | ||||||
|
|
||||||
| 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. | ||||||
|
||||||
| - `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. |
Copilot
AI
Apr 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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. |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -111,6 +111,111 @@ function isConfigured() { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async function syncAllowedOrigins() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const origins = [`http://localhost:${INTERNAL_GATEWAY_PORT}`, `http://127.0.0.1:${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}`]; |
Copilot
AI
Apr 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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(() => {}); |
Copilot
AI
Apr 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Copilot
AI
Apr 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Copilot
AI
Apr 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Copilot
AI
Apr 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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)"}`); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.