From 4e8c649c2b56deb52d469e5d68ad7a951853bbf5 Mon Sep 17 00:00:00 2001 From: mahdiwafy Date: Wed, 29 Jul 2026 22:25:32 +0700 Subject: [PATCH] fix(tailscale): bundle Docker binaries and support host socket --- .env.example | 14 +++++++++ Dockerfile | 28 +++++++++++++++-- docker-compose.yml | 5 +++ src/lib/tunnel/tailscale/tailscale.js | 44 +++++++++++++++++++++------ 4 files changed, 80 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index b013f9ab67..242d8d790e 100644 --- a/.env.example +++ b/.env.example @@ -50,6 +50,20 @@ REQUIRE_API_KEY=false # Set true if serving behind HTTPS reverse proxy. AUTH_COOKIE_SECURE=false +# --- Public API hosts (Optional) --- + +# Comma-separated hostnames that are trusted for public /v1/* API access. +# Use this when serving through a reverse proxy / tunnel domain (e.g. Cloudflare). +# Example: PUBLIC_API_HOSTS=router.mahdiwafy.my.id +PUBLIC_API_HOSTS= + +# Use the host's Tailscale daemon socket instead of launching a bundled daemon. +# Mount /var/run/tailscale/tailscaled.sock into the container when enabled. +TAILSCALE_USE_HOST_SOCKET=false + +# Path to the host Tailscale socket (only used when TAILSCALE_USE_HOST_SOCKET=true). +TAILSCALE_HOST_SOCKET=/var/run/tailscale/tailscaled.sock + # --- Observability --- # Enable detailed request logging (high volume). diff --git a/Dockerfile b/Dockerfile index a98bbdadec..1b84f8a3ed 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,26 @@ COPY . ./ ENV NEXT_TELEMETRY_DISABLED=1 RUN npm run build +# Tailscale static binaries for Alpine Linux (bundled so tunnel works in Docker). +# Fetches the latest stable tailscale and tailscaled. Override with --build-arg. +FROM alpine:3.19 AS tailscale +ARG TAILSCALE_VERSION=1.80.3 +ARG TARGETARCH +RUN apk add --no-cache curl ca-certificates && \ + mkdir -p /out && \ + TARCH=${TARGETARCH:-amd64} && \ + case "$TARCH" in \ + amd64) TS_ARCH=amd64 ;; \ + arm64) TS_ARCH=arm64 ;; \ + arm) TS_ARCH=arm ;; \ + *) echo "Unsupported arch: $TARCH"; exit 1 ;; \ + esac && \ + curl -fsSL "https://pkgs.tailscale.com/stable/tailscale_${TAILSCALE_VERSION}_${TS_ARCH}.tgz" -o /tmp/tailscale.tgz && \ + tar -xzf /tmp/tailscale.tgz -C /tmp && \ + cp /tmp/tailscale_${TAILSCALE_VERSION}_${TS_ARCH}/tailscale /out/tailscale && \ + cp /tmp/tailscale_${TAILSCALE_VERSION}_${TS_ARCH}/tailscaled /out/tailscaled && \ + chmod +x /out/tailscale /out/tailscaled + FROM ${NODE_IMAGE} AS runner WORKDIR /app @@ -43,13 +63,17 @@ COPY --from=builder /app/src/mitm ./src/mitm COPY --from=builder /app/node_modules/node-forge ./node_modules/node-forge # Ensure `next` is available at runtime in case tracing did not include it. COPY --from=builder /app/node_modules/next ./node_modules/next +# Bundle Tailscale binaries into /usr/local/bin so they survive the /app/data volume mount. +COPY --from=tailscale /out/tailscale /usr/local/bin/tailscale +COPY --from=tailscale /out/tailscaled /usr/local/bin/tailscaled RUN mkdir -p /app/data && chown -R node:node /app && \ mkdir -p /app/data-home && chown node:node /app/data-home && \ ln -sf /app/data-home /root/.9router 2>/dev/null || true -# Fix permissions at runtime (handles mounted volumes) -RUN apk --no-cache upgrade && apk --no-cache add su-exec && \ +# Tailscale Funnel requires CAP_NET_ADMIN for TUN mode; keep su-exec for dropping privileges. +# When using host socket mode (TAILSCALE_USE_HOST_SOCKET=true), no extra capability is needed. +RUN apk --no-cache upgrade && apk --no-cache add su-exec ip6tables iptables && \ printf '#!/bin/sh\nchown -R node:node /app/data /app/data-home 2>/dev/null\nexec su-exec node "$@"\n' > /entrypoint.sh && \ chmod +x /entrypoint.sh diff --git a/docker-compose.yml b/docker-compose.yml index 547025cfbb..41309ec1e0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,9 @@ services: - "${PORT:-20128}:20128" volumes: - 9router-data:/app/data + # Optional: use the host's Tailscale daemon instead of the bundled one. + # Uncomment the line below and set TAILSCALE_USE_HOST_SOCKET=true in .env + # - /var/run/tailscale/tailscaled.sock:/var/run/tailscale/tailscaled.sock env_file: - .env environment: @@ -21,6 +24,8 @@ services: HOSTNAME: "0.0.0.0" NODE_ENV: production HEADROOM_URL: http://headroom:8787 + # Set TAILSCALE_USE_HOST_SOCKET=true to reuse the host Tailscale daemon. + # TAILSCALE_USE_HOST_SOCKET: "true" depends_on: headroom: condition: service_started diff --git a/src/lib/tunnel/tailscale/tailscale.js b/src/lib/tunnel/tailscale/tailscale.js index 64698d88de..ea4a084357 100644 --- a/src/lib/tunnel/tailscale/tailscale.js +++ b/src/lib/tunnel/tailscale/tailscale.js @@ -10,15 +10,25 @@ import { DATA_DIR } from "@/lib/dataDir.js"; const execAsync = promisify(exec); const BIN_DIR = path.join(DATA_DIR, "bin"); +const TAILSCALE_BIN_DIR = path.join(BIN_DIR, "tailscale"); +const BUNDLED_TAILSCALE_BIN_DIR = "/usr/local/bin"; const IS_MAC = os.platform() === "darwin"; const IS_LINUX = os.platform() === "linux"; const IS_WINDOWS = os.platform() === "win32"; -const TAILSCALE_BIN = path.join(BIN_DIR, IS_WINDOWS ? "tailscale.exe" : "tailscale"); +const TAILSCALE_BIN = path.join(TAILSCALE_BIN_DIR, IS_WINDOWS ? "tailscale.exe" : "tailscale"); +const TAILSCALED_BIN = path.join(TAILSCALE_BIN_DIR, IS_WINDOWS ? "tailscaled.exe" : "tailscaled"); +const BUNDLED_TAILSCALE_BIN = path.join(BUNDLED_TAILSCALE_BIN_DIR, IS_WINDOWS ? "tailscale.exe" : "tailscale"); +const BUNDLED_TAILSCALED_BIN = path.join(BUNDLED_TAILSCALE_BIN_DIR, IS_WINDOWS ? "tailscaled.exe" : "tailscaled"); // Custom socket for userspace-networking mode (no root required) const TAILSCALE_DIR = path.join(DATA_DIR, "tailscale"); export const TAILSCALE_SOCKET = path.join(TAILSCALE_DIR, "tailscaled.sock"); -const SOCKET_FLAG = IS_WINDOWS ? [] : ["--socket", TAILSCALE_SOCKET]; + +// When TAILSCALE_USE_HOST_SOCKET=true, reuse the host's tailscaled via a mounted socket. +const USE_HOST_SOCKET = process.env.TAILSCALE_USE_HOST_SOCKET === "true"; +const HOST_TAILSCALE_SOCKET = process.env.TAILSCALE_HOST_SOCKET || "/var/run/tailscale/tailscaled.sock"; +export const ACTIVE_TAILSCALE_SOCKET = USE_HOST_SOCKET ? HOST_TAILSCALE_SOCKET : TAILSCALE_SOCKET; +const SOCKET_FLAG = IS_WINDOWS ? [] : ["--socket", ACTIVE_TAILSCALE_SOCKET]; // System daemon socket (sudo install: apt/snap/systemd) — read-only status detection const SYSTEM_TAILSCALE_SOCKET = IS_WINDOWS ? null : "/var/run/tailscale/tailscaled.sock"; @@ -47,6 +57,9 @@ const funnelUrlCache = { value: null, port: null, fetchedAt: 0, refreshing: fals function fallbackBin() { if (fs.existsSync(TAILSCALE_BIN)) return TAILSCALE_BIN; + if (fs.existsSync(BUNDLED_TAILSCALE_BIN)) return BUNDLED_TAILSCALE_BIN; + if (fs.existsSync(TAILSCALED_BIN)) return TAILSCALED_BIN; + if (fs.existsSync(BUNDLED_TAILSCALED_BIN)) return BUNDLED_TAILSCALED_BIN; if (IS_WINDOWS && fs.existsSync(WINDOWS_TAILSCALE_BIN)) return WINDOWS_TAILSCALE_BIN; if (!IS_WINDOWS) return UNIX_TAILSCALE_CANDIDATES.find((p) => fs.existsSync(p)) || null; return null; @@ -74,6 +87,9 @@ export function getTailscaleBin() { // First call: synchronously probe common install paths (no exec, no event-loop block) if (binCache.value === undefined) { if (fs.existsSync(TAILSCALE_BIN)) binCache.value = TAILSCALE_BIN; + else if (fs.existsSync(BUNDLED_TAILSCALE_BIN)) binCache.value = BUNDLED_TAILSCALE_BIN; + else if (fs.existsSync(TAILSCALED_BIN)) binCache.value = TAILSCALED_BIN; + else if (fs.existsSync(BUNDLED_TAILSCALED_BIN)) binCache.value = BUNDLED_TAILSCALED_BIN; else if (IS_WINDOWS && fs.existsSync(WINDOWS_TAILSCALE_BIN)) binCache.value = WINDOWS_TAILSCALE_BIN; else if (!IS_WINDOWS) { const found = UNIX_TAILSCALE_CANDIDATES.find((p) => fs.existsSync(p)); @@ -287,7 +303,7 @@ export async function installTailscale(sudoPassword, hostname, onProgress) { return startLogin(hostname); } -const EXTENDED_PATH = `/usr/local/bin:/opt/homebrew/bin:/usr/sbin:/usr/bin:/bin:/snap/bin:${process.env.PATH || ""}`; +const EXTENDED_PATH = `${BUNDLED_TAILSCALE_BIN_DIR}:${TAILSCALE_BIN_DIR}:/usr/local/bin:/opt/homebrew/bin:/usr/sbin:/usr/bin:/bin:/snap/bin:${process.env.PATH || ""}`; function hasBrew() { try { execSync("which brew", { stdio: "ignore", windowsHide: true, env: { ...process.env, PATH: EXTENDED_PATH } }); return true; } catch { return false; } @@ -521,7 +537,7 @@ async function ensureUserOwnedDir(dir) { /** Check if running daemon uses TUN mode (Funnel TLS requires TUN). */ function isDaemonTunMode() { try { - const ps = execSync(`pgrep -af "tailscaled.*${TAILSCALE_SOCKET}"`, { encoding: "utf8", timeout: 2000 }).trim(); + const ps = execSync(`pgrep -af "tailscaled.*${ACTIVE_TAILSCALE_SOCKET}"`, { encoding: "utf8", timeout: 2000 }).trim(); if (!ps) return null; return !ps.includes("--tun=userspace-networking"); } catch { return null; } @@ -569,6 +585,12 @@ export async function startDaemonWithPassword(sudoPassword) { return; } + // When using host socket, never spawn our own daemon. + if (USE_HOST_SOCKET) { + console.log("[Tailscale] TAILSCALE_USE_HOST_SOCKET enabled — skipping local daemon spawn"); + return; + } + const currentMode = isDaemonTunMode(); // true=TUN, false=userspace, null=not running // No password but a healthy TUN daemon already runs → keep TUN, never downgrade-kill it. const wantTun = sudoPassword ? true : currentMode === true; @@ -586,20 +608,24 @@ export async function startDaemonWithPassword(sudoPassword) { } // Mode mismatch or unresponsive → kill all daemons on our socket - try { execSync(`pkill -9 -f "tailscaled.*${TAILSCALE_SOCKET}"`, { stdio: "ignore", timeout: 3000 }); } catch { /* ignore */ } + try { execSync(`pkill -9 -f "tailscaled.*${ACTIVE_TAILSCALE_SOCKET}"`, { stdio: "ignore", timeout: 3000 }); } catch { /* ignore */ } if (sudoPassword) { - try { await execWithPassword(`pkill -9 -f "tailscaled.*${TAILSCALE_SOCKET}"`, sudoPassword); } catch { /* ignore */ } + try { await execWithPassword(`pkill -9 -f "tailscaled.*${ACTIVE_TAILSCALE_SOCKET}"`, sudoPassword); } catch { /* ignore */ } } else { - try { execSync(`sudo -n pkill -9 -f "tailscaled.*${TAILSCALE_SOCKET}"`, { stdio: "ignore", timeout: 3000 }); } catch { /* ignore */ } + try { execSync(`sudo -n pkill -9 -f "tailscaled.*${ACTIVE_TAILSCALE_SOCKET}"`, { stdio: "ignore", timeout: 3000 }); } catch { /* ignore */ } } await new Promise((r) => setTimeout(r, 1500)); // Reclaim folder ownership (previous root daemon may have locked it) await ensureUserOwnedDir(TAILSCALE_DIR); - const tailscaledBin = IS_MAC ? "/usr/local/bin/tailscaled" : "tailscaled"; + const tailscaledBin = fs.existsSync(TAILSCALED_BIN) + ? TAILSCALED_BIN + : fs.existsSync(BUNDLED_TAILSCALED_BIN) + ? BUNDLED_TAILSCALED_BIN + : (IS_MAC ? "/usr/local/bin/tailscaled" : "tailscaled"); const daemonArgs = [ - `--socket=${TAILSCALE_SOCKET}`, + `--socket=${ACTIVE_TAILSCALE_SOCKET}`, `--statedir=${TAILSCALE_DIR}`, ]; if (!wantTun) daemonArgs.push("--tun=userspace-networking");