diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts new file mode 100644 index 000000000..705b59f18 --- /dev/null +++ b/src/gateway/auth.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; + +import { authorizeGatewayConnect } from "./auth.js"; + +describe("gateway auth", () => { + it("does not throw when req is missing socket", async () => { + const res = await authorizeGatewayConnect({ + auth: { mode: "none", allowTailscale: false }, + connectAuth: null, + // Regression: avoid crashing on req.socket.remoteAddress when callers pass a non-IncomingMessage. + req: {} as never, + }); + expect(res.ok).toBe(true); + }); +}); diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts new file mode 100644 index 000000000..b1e543200 --- /dev/null +++ b/src/gateway/auth.ts @@ -0,0 +1,200 @@ +import { timingSafeEqual } from "node:crypto"; +import type { IncomingMessage } from "node:http"; +import os from "node:os"; + +import { type PamAvailability, verifyPamCredentials } from "../infra/pam.js"; + +export type ResolvedGatewayAuthMode = "none" | "token" | "password" | "system"; + +export type ResolvedGatewayAuth = { + mode: ResolvedGatewayAuthMode; + token?: string; + password?: string; + username?: string; + allowTailscale: boolean; +}; + +export type GatewayAuthResult = { + ok: boolean; + method?: "none" | "token" | "password" | "system" | "tailscale"; + user?: string; + reason?: string; +}; + +type ConnectAuth = { + token?: string; + username?: string; + password?: string; +}; + +type TailscaleUser = { + login: string; + name: string; + profilePic?: string; +}; + +function safeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + return timingSafeEqual(Buffer.from(a), Buffer.from(b)); +} + +function isLoopbackAddress(ip: string | undefined): boolean { + if (!ip) return false; + if (ip === "127.0.0.1") return true; + if (ip.startsWith("127.")) return true; + if (ip === "::1") return true; + if (ip.startsWith("::ffff:127.")) return true; + return false; +} + +function isLocalDirectRequest(req?: IncomingMessage): boolean { + if (!req) return false; + const clientIp = req.socket?.remoteAddress ?? ""; + if (!isLoopbackAddress(clientIp)) return false; + + const host = (req.headers.host ?? "").toLowerCase(); + const hostIsLocal = + host.startsWith("localhost") || + host.startsWith("127.0.0.1") || + host.startsWith("[::1]"); + + const hasForwarded = Boolean( + req.headers["x-forwarded-for"] || + req.headers["x-real-ip"] || + req.headers["x-forwarded-host"], + ); + + return hostIsLocal && !hasForwarded; +} + +function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null { + if (!req) return null; + const login = req.headers["tailscale-user-login"]; + if (typeof login !== "string" || !login.trim()) return null; + const nameRaw = req.headers["tailscale-user-name"]; + const profilePic = req.headers["tailscale-user-profile-pic"]; + const name = + typeof nameRaw === "string" && nameRaw.trim() + ? nameRaw.trim() + : login.trim(); + return { + login: login.trim(), + name, + profilePic: + typeof profilePic === "string" && profilePic.trim() + ? profilePic.trim() + : undefined, + }; +} + +function hasTailscaleProxyHeaders(req?: IncomingMessage): boolean { + if (!req) return false; + return Boolean( + req.headers["x-forwarded-for"] && + req.headers["x-forwarded-proto"] && + req.headers["x-forwarded-host"], + ); +} + +function isTailscaleProxyRequest(req?: IncomingMessage): boolean { + if (!req) return false; + return ( + isLoopbackAddress(req.socket?.remoteAddress) && + hasTailscaleProxyHeaders(req) + ); +} + +export function assertGatewayAuthConfigured( + auth: ResolvedGatewayAuth, + pam: PamAvailability, +): void { + if (auth.mode === "token" && !auth.token) { + throw new Error( + "gateway auth mode is token, but CLAWDIS_GATEWAY_TOKEN is not set", + ); + } + if (auth.mode === "password" && !auth.password) { + throw new Error( + "gateway auth mode is password, but no password was configured", + ); + } + if (auth.mode === "system" && !pam.available) { + throw new Error( + `gateway auth mode is system, but PAM auth is unavailable${ + pam.error ? `: ${pam.error}` : "" + }`, + ); + } +} + +export async function authorizeGatewayConnect(params: { + auth: ResolvedGatewayAuth; + connectAuth?: ConnectAuth | null; + req?: IncomingMessage; +}): Promise { + const { auth, connectAuth, req } = params; + const localDirect = isLocalDirectRequest(req); + + if (auth.mode === "none") { + if (auth.allowTailscale && !localDirect) { + const tailscaleUser = getTailscaleUser(req); + if (!tailscaleUser) { + return { ok: false, reason: "unauthorized" }; + } + if (!isTailscaleProxyRequest(req)) { + return { ok: false, reason: "unauthorized" }; + } + return { + ok: true, + method: "tailscale", + user: tailscaleUser.login, + }; + } + return { ok: true, method: "none" }; + } + + if (auth.mode === "token") { + if (auth.token && connectAuth?.token === auth.token) { + return { ok: true, method: "token" }; + } + } + + if (auth.mode === "password") { + const password = connectAuth?.password; + if (!password || !auth.password) { + return { ok: false, reason: "unauthorized" }; + } + if (!safeEqual(password, auth.password)) { + return { ok: false, reason: "unauthorized" }; + } + return { ok: true, method: "password" }; + } + + if (auth.mode === "system") { + const password = connectAuth?.password; + if (!password) return { ok: false, reason: "unauthorized" }; + const username = ( + connectAuth?.username ?? + auth.username ?? + os.userInfo().username + ).trim(); + if (!username) return { ok: false, reason: "unauthorized" }; + const ok = await verifyPamCredentials(username, password); + return ok + ? { ok: true, method: "system", user: username } + : { ok: false, reason: "unauthorized" }; + } + + if (auth.allowTailscale) { + const tailscaleUser = getTailscaleUser(req); + if (tailscaleUser && isTailscaleProxyRequest(req)) { + return { + ok: true, + method: "tailscale", + user: tailscaleUser.login, + }; + } + } + + return { ok: false, reason: "unauthorized" }; +} diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 54a4dbaf9..fb732488a 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -73,6 +73,7 @@ import { requestNodePairing, verifyNodeToken, } from "../infra/node-pairing.js"; +import { getPamAvailability } from "../infra/pam.js"; import { ensureClawdisCliOnPath } from "../infra/path-env.js"; import { enqueueSystemEvent, @@ -87,7 +88,13 @@ import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6, } from "../infra/tailnet.js"; -import { getTailnetHostname } from "../infra/tailscale.js"; +import { + disableTailscaleFunnel, + disableTailscaleServe, + enableTailscaleFunnel, + enableTailscaleServe, + getTailnetHostname, +} from "../infra/tailscale.js"; import { defaultVoiceWakeTriggers, loadVoiceWakeConfig, @@ -115,6 +122,11 @@ import { startWebLoginWithQr, waitForWebLogin } from "../web/login-qr.js"; import { sendMessageWhatsApp } from "../web/outbound.js"; import { requestReplyHeartbeatNow } from "../web/reply-heartbeat-wake.js"; import { getWebAuthAgeMs, logoutWeb, readWebSelfId } from "../web/session.js"; +import { + assertGatewayAuthConfigured, + authorizeGatewayConnect, + type ResolvedGatewayAuth, +} from "./auth.js"; import { buildMessageWithAttachments } from "./chat-attachments.js"; import { handleControlUiHttpRequest } from "./control-ui.js"; @@ -420,6 +432,14 @@ export type GatewayServerOptions = { * Default: config `gateway.controlUi.enabled` (or true when absent). */ controlUiEnabled?: boolean; + /** + * Override gateway auth configuration (merges with config). + */ + auth?: import("../config/config.js").GatewayAuthConfig; + /** + * Override gateway Tailscale exposure configuration (merges with config). + */ + tailscale?: import("../config/config.js").GatewayTailscaleConfig; /** * Test-only: allow canvas host startup even when NODE_ENV/VITEST would disable it. */ @@ -1097,12 +1117,52 @@ export async function startGatewayServer( } const controlUiEnabled = opts.controlUiEnabled ?? cfgAtStart.gateway?.controlUi?.enabled ?? true; + const authConfig = { + ...(cfgAtStart.gateway?.auth ?? {}), + ...(opts.auth ?? {}), + }; + const tailscaleConfig = { + ...(cfgAtStart.gateway?.tailscale ?? {}), + ...(opts.tailscale ?? {}), + }; + const tailscaleMode = tailscaleConfig.mode ?? "off"; + const token = getGatewayToken(); + const password = + authConfig.password ?? process.env.CLAWDIS_GATEWAY_PASSWORD ?? undefined; + const username = + authConfig.username ?? process.env.CLAWDIS_GATEWAY_USERNAME ?? undefined; + const authMode: ResolvedGatewayAuth["mode"] = + authConfig.mode ?? (password ? "password" : token ? "token" : "none"); + const allowTailscale = + authConfig.allowTailscale ?? + (tailscaleMode === "serve" && + authMode !== "password" && + authMode !== "system"); + const resolvedAuth: ResolvedGatewayAuth = { + mode: authMode, + token, + password, + username, + allowTailscale, + }; const canvasHostEnabled = process.env.CLAWDIS_SKIP_CANVAS_HOST !== "1" && cfgAtStart.canvasHost?.enabled !== false; - if (!isLoopbackHost(bindHost) && !getGatewayToken()) { + const pamAvailability = await getPamAvailability(); + assertGatewayAuthConfigured(resolvedAuth, pamAvailability); + if (tailscaleMode === "funnel" && authMode === "none") { throw new Error( - `refusing to bind gateway to ${bindHost}:${port} without CLAWDIS_GATEWAY_TOKEN`, + "tailscale funnel requires gateway auth (set gateway.auth or CLAWDIS_GATEWAY_TOKEN)", + ); + } + if (tailscaleMode !== "off" && !isLoopbackHost(bindHost)) { + throw new Error( + "tailscale serve/funnel requires gateway bind=loopback (127.0.0.1)", + ); + } + if (!isLoopbackHost(bindHost) && authMode === "none") { + throw new Error( + `refusing to bind gateway to ${bindHost}:${port} without auth (set gateway.auth or CLAWDIS_GATEWAY_TOKEN)`, ); } @@ -2618,7 +2678,7 @@ export async function startGatewayServer( .start() .catch((err) => logError(`cron failed to start: ${String(err)}`)); - wss.on("connection", (socket, req) => { + wss.on("connection", (socket, upgradeReq) => { let client: Client | null = null; let closed = false; const connId = randomUUID(); @@ -2632,7 +2692,7 @@ export async function startGatewayServer( ? bridgeHost : undefined; const canvasHostUrl = deriveCanvasHostUrl( - req, + upgradeReq, canvasHostPortForWs, canvasHostServer ? canvasHostOverride : undefined, ); @@ -2746,8 +2806,8 @@ export async function startGatewayServer( return; } - const req = parsed as RequestFrame; - const connectParams = req.params as ConnectParams; + const frame = parsed as RequestFrame; + const connectParams = frame.params as ConnectParams; // protocol negotiation const { minProtocol, maxProtocol } = connectParams; @@ -2760,7 +2820,7 @@ export async function startGatewayServer( ); send({ type: "res", - id: req.id, + id: frame.id, ok: false, error: errorShape( ErrorCodes.INVALID_REQUEST, @@ -2775,15 +2835,18 @@ export async function startGatewayServer( return; } - // token auth if required - const token = getGatewayToken(); - if (token && connectParams.auth?.token !== token) { + const authResult = await authorizeGatewayConnect({ + auth: resolvedAuth, + connectAuth: connectParams.auth, + req: upgradeReq, + }); + if (!authResult.ok) { logWarn( `[gws] unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${connectParams.client.name} ${connectParams.client.mode} v${connectParams.client.version}`, ); send({ type: "res", - id: req.id, + id: frame.id, ok: false, error: errorShape(ErrorCodes.INVALID_REQUEST, "unauthorized"), }); @@ -2791,6 +2854,7 @@ export async function startGatewayServer( close(); return; } + const authMethod = authResult.method ?? "none"; const shouldTrackPresence = connectParams.client.mode !== "cli"; const presenceKey = shouldTrackPresence @@ -2804,7 +2868,7 @@ export async function startGatewayServer( mode: connectParams.client.mode, instanceId: connectParams.client.instanceId, platform: connectParams.client.platform, - token: connectParams.auth?.token ? "set" : "none", + auth: authMethod, }); if (isWebchatConnect(connectParams)) { @@ -2866,7 +2930,7 @@ export async function startGatewayServer( stateVersion: snapshot.stateVersion.presence, }); - send({ type: "res", id: req.id, ok: true, payload: helloOk }); + send({ type: "res", id: frame.id, ok: true, payload: helloOk }); clients.add(client); void refreshHealthSnapshot({ probe: true }).catch((err) => @@ -4891,6 +4955,43 @@ export async function startGatewayServer( `gateway listening on ws://${bindHost}:${port} (PID ${process.pid})`, ); defaultRuntime.log(`gateway log file: ${getResolvedLoggerSettings().file}`); + let tailscaleCleanup: (() => Promise) | null = null; + if (tailscaleMode !== "off") { + try { + if (tailscaleMode === "serve") { + await enableTailscaleServe(port); + } else { + await enableTailscaleFunnel(port); + } + const host = await getTailnetHostname().catch(() => null); + if (host) { + logInfo( + `tailscale ${tailscaleMode} enabled: https://${host}/ui/ (WS via wss://${host})`, + ); + } else { + logInfo(`tailscale ${tailscaleMode} enabled`); + } + } catch (err) { + logWarn( + `tailscale ${tailscaleMode} failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + if (tailscaleConfig.resetOnExit) { + tailscaleCleanup = async () => { + try { + if (tailscaleMode === "serve") { + await disableTailscaleServe(); + } else { + await disableTailscaleFunnel(); + } + } catch (err) { + logWarn( + `tailscale ${tailscaleMode} cleanup failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + }; + } + } // Start clawd browser control server (unless disabled via config). void startBrowserControlServerIfEnabled().catch((err) => { @@ -4916,6 +5017,9 @@ export async function startGatewayServer( /* ignore */ } } + if (tailscaleCleanup) { + await tailscaleCleanup(); + } if (canvasHost) { try { await canvasHost.close(); diff --git a/src/infra/pam.ts b/src/infra/pam.ts new file mode 100644 index 000000000..2ed607798 --- /dev/null +++ b/src/infra/pam.ts @@ -0,0 +1,61 @@ +type PamAuthenticate = ( + username: string, + password: string, + callback: (err: Error | null) => void, +) => void; + +let pamAuth: PamAuthenticate | null | undefined; +let pamError: string | null = null; + +async function loadPam(): Promise { + if (pamAuth !== undefined) return; + try { + // Vite/Vitest: avoid static analysis/bundling for optional native deps. + const pkgName = "authenticate-pam"; + const mod = (await import(pkgName)) as + | { authenticate?: PamAuthenticate; default?: PamAuthenticate } + | PamAuthenticate; + const candidate = + typeof mod === "function" + ? mod + : typeof (mod as { authenticate?: PamAuthenticate }).authenticate === + "function" + ? (mod as { authenticate: PamAuthenticate }).authenticate + : typeof (mod as { default?: PamAuthenticate }).default === "function" + ? (mod as { default: PamAuthenticate }).default + : null; + if (!candidate) { + throw new Error( + "authenticate-pam did not export an authenticate function", + ); + } + pamAuth = candidate; + } catch (err) { + pamAuth = null; + pamError = err instanceof Error ? err.message : String(err); + } +} + +export type PamAvailability = { + available: boolean; + error?: string; +}; + +export async function getPamAvailability(): Promise { + await loadPam(); + return pamAuth + ? { available: true } + : { available: false, error: pamError ?? undefined }; +} + +export async function verifyPamCredentials( + username: string, + password: string, +): Promise { + await loadPam(); + const auth = pamAuth; + if (!auth) return false; + return await new Promise((resolve) => { + auth(username, password, (err) => resolve(!err)); + }); +}