import { timingSafeEqual } from "node:crypto"; import type { IncomingMessage } from "node:http"; import type { GatewayAuthConfig, GatewayTailscaleMode, } from "../config/config.js"; export type ResolvedGatewayAuthMode = "none" | "token" | "password"; export type ResolvedGatewayAuth = { mode: ResolvedGatewayAuthMode; token?: string; password?: string; allowTailscale: boolean; }; export type GatewayAuthResult = { ok: boolean; method?: "none" | "token" | "password" | "tailscale"; user?: string; reason?: string; }; type ConnectAuth = { token?: 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 resolveGatewayAuth(params: { authConfig?: GatewayAuthConfig | null; env?: NodeJS.ProcessEnv; tailscaleMode?: GatewayTailscaleMode; }): ResolvedGatewayAuth { const authConfig = params.authConfig ?? {}; const env = params.env ?? process.env; const token = authConfig.token ?? env.CLAWDBOT_GATEWAY_TOKEN ?? undefined; const password = authConfig.password ?? env.CLAWDBOT_GATEWAY_PASSWORD ?? undefined; const mode: ResolvedGatewayAuth["mode"] = authConfig.mode ?? (password ? "password" : token ? "token" : "none"); const allowTailscale = authConfig.allowTailscale ?? (params.tailscaleMode === "serve" && mode !== "password"); return { mode, token, password, allowTailscale, }; } export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void { if (auth.mode === "token" && !auth.token) { throw new Error( "gateway auth mode is token, but no token was configured (set gateway.auth.token or CLAWDBOT_GATEWAY_TOKEN)", ); } if (auth.mode === "password" && !auth.password) { throw new Error( "gateway auth mode is password, but no password was configured", ); } } 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: "tailscale_user_missing" }; } if (!isTailscaleProxyRequest(req)) { return { ok: false, reason: "tailscale_proxy_missing" }; } return { ok: true, method: "tailscale", user: tailscaleUser.login, }; } return { ok: true, method: "none" }; } if (auth.mode === "token") { if (!auth.token) { return { ok: false, reason: "token_missing_config" }; } if (!connectAuth?.token) { return { ok: false, reason: "token_missing" }; } if (connectAuth.token !== auth.token) { return { ok: false, reason: "token_mismatch" }; } return { ok: true, method: "token" }; } if (auth.mode === "password") { const password = connectAuth?.password; if (!auth.password) { return { ok: false, reason: "password_missing_config" }; } if (!password) { return { ok: false, reason: "password_missing" }; } if (!safeEqual(password, auth.password)) { return { ok: false, reason: "password_mismatch" }; } return { ok: true, method: "password" }; } if (auth.allowTailscale) { const tailscaleUser = getTailscaleUser(req); if (!tailscaleUser) { return { ok: false, reason: "tailscale_user_missing" }; } if (!isTailscaleProxyRequest(req)) { return { ok: false, reason: "tailscale_proxy_missing" }; } return { ok: true, method: "tailscale", user: tailscaleUser.login, }; } return { ok: false, reason: "unauthorized" }; }