Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
28 changes: 26 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
44 changes: 35 additions & 9 deletions src/lib/tunnel/tailscale/tailscale.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -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;
Expand All @@ -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");
Expand Down
Loading