Skip to content

feat: add optional Tailscale sidecar for secure private access#210

Open
QuantDeveloperUSA wants to merge 12 commits into
vignesh07:mainfrom
ABFS-Inc:feat/tailscale
Open

feat: add optional Tailscale sidecar for secure private access#210
QuantDeveloperUSA wants to merge 12 commits into
vignesh07:mainfrom
ABFS-Inc:feat/tailscale

Conversation

@QuantDeveloperUSA

Copy link
Copy Markdown

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

  • If TS_AUTHKEY and TS_HOSTNAME are set as Railway Variables, ailscaled starts automatically in userspace-networking mode on container boot.
  • After the OpenClaw gateway is ready, ailscale serve proxies HTTPS traffic from https://. to the wrapper on port 8080 — giving a valid TLS certificate automatically.
  • gateway.auth.allowTailscale is enabled in the OpenClaw config so Tailscale-authenticated connections are trusted.
  • gateway.controlUi.allowedOrigins is populated with all valid Tailscale identities (FQDN with HTTPS, short hostname and IPs with HTTP+port) so the Control UI works from any access point.

Changes

File Change
\Dockerfile\ Install Tailscale via official install script
\src/server.js\ Add \ensureTailscaleBoot(), \ensureTailscaleServe(), \syncAllowedOrigins()\
\src/server.js\ Fix \waitForGatewayReady()\ to use TCP probe instead of HTTP fetch (gateway speaks WebSocket)
\src/server.js\ Fix \�ttachGatewayAuthHeader()\ to always overwrite Authorization header (Tailscale HTTPS proxies existing Basic auth header through)
\README.md\ Document Tailscale setup variables

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.

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.
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).
Copilot AI review requested due to automatic review settings April 3, 2026 20:35

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 serve enablement in the wrapper (optional via TS_AUTHKEY / TS_HOSTNAME).
  • Replace gateway readiness check with a TCP probe (gateway speaks WebSocket).
  • Sync gateway Control UI allowedOrigins, and always overwrite proxied Authorization with 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.

Comment thread src/server.js
}

async function syncAllowedOrigins() {
const origins = [`http://localhost:${INTERNAL_GATEWAY_PORT}`, `http://127.0.0.1:${INTERNAL_GATEWAY_PORT}`];

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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 uses AI. Check for mistakes.
Comment thread src/server.js
Comment on lines +176 to +184
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) };

Copilot AI Apr 3, 2026

Copy link

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 uses AI. Check for mistakes.
Comment thread src/server.js
Comment on lines +202 to +216
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) };
}

Copilot AI Apr 3, 2026

Copy link

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 uses AI. Check for mistakes.
Comment thread src/server.js
Comment on lines +154 to +168
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);

Copilot AI Apr 3, 2026

Copy link

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 uses AI. Check for mistakes.
Comment thread src/server.js
Comment on lines +1523 to +1524
console.log(`[wrapper] TS_AUTHKEY: ${debugPrefix(process.env.TS_AUTHKEY)}`);
console.log(`[wrapper] TS_HOSTNAME: ${debugPrefix(process.env.TS_HOSTNAME)}`);

Copilot AI Apr 3, 2026

Copy link

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.

Suggested change
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)"}`);

Copilot uses AI. Check for mistakes.
Comment thread Dockerfile
Comment on lines 50 to +53
tini \
python3 \
python3-venv \
&& curl -fsSL https://tailscale.com/install.sh | sh \

Copilot AI Apr 3, 2026

Copy link

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.

Suggested change
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 \

Copilot uses AI. Check for mistakes.
Comment thread README.md

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.

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
- `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 uses AI. Check for mistakes.
Comment thread README.md
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.

Copilot AI Apr 3, 2026

Copy link

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).

Suggested change
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.

Copilot uses AI. Check for mistakes.
Comment thread src/server.js
Comment on lines +114 to +139
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(() => {});

Copilot AI Apr 3, 2026

Copy link

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.

Suggested change
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 uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants