import type { IncomingMessage } from "node:http"; import { timingSafeEqual } from "node:crypto"; import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js"; import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js"; import { GATEWAY_CLIENT_IDS, type GatewayClientInfo } from "./protocol/client-info.js"; import { readControlUiOauthSessionIdentityFromRequest } from "./control-ui.js"; import { isTrustedProxyAddress, parseForwardedForClientIp, resolveGatewayClientIp } from "./net.js"; export type ResolvedGatewayAuthMode = "token" | "password"; export type ResolvedGatewayAuth = { mode: ResolvedGatewayAuthMode; token?: string; password?: string; allowTailscale: boolean; }; export type GatewayAuthResult = { ok: boolean; method?: "token" | "password" | "tailscale" | "device-token" | "control-ui-oauth"; user?: string; reason?: string; }; type ConnectAuth = { token?: string; password?: string; }; type TailscaleUser = { login: string; name: string; profilePic?: string; }; type TailscaleWhoisLookup = (ip: string) => Promise; function safeEqual(a: string, b: string): boolean { if (a.length !== b.length) { return false; } return timingSafeEqual(Buffer.from(a), Buffer.from(b)); } function normalizeLogin(login: string): string { return login.trim().toLowerCase(); } 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 getHostName(hostHeader?: string): string { const host = (hostHeader ?? "").trim().toLowerCase(); if (!host) { return ""; } if (host.startsWith("[")) { const end = host.indexOf("]"); if (end !== -1) { return host.slice(1, end); } } const [name] = host.split(":"); return name ?? ""; } function headerValue(value: string | string[] | undefined): string | undefined { return Array.isArray(value) ? value[0] : value; } function resolveTailscaleClientIp(req?: IncomingMessage): string | undefined { if (!req) { return undefined; } const forwardedFor = headerValue(req.headers?.["x-forwarded-for"]); return forwardedFor ? parseForwardedForClientIp(forwardedFor) : undefined; } function resolveRequestClientIp( req?: IncomingMessage, trustedProxies?: string[], ): string | undefined { if (!req) { return undefined; } return resolveGatewayClientIp({ remoteAddr: req.socket?.remoteAddress ?? "", forwardedFor: headerValue(req.headers?.["x-forwarded-for"]), realIp: headerValue(req.headers?.["x-real-ip"]), trustedProxies, }); } export function isLocalDirectRequest(req?: IncomingMessage, trustedProxies?: string[]): boolean { if (!req) { return false; } const clientIp = resolveRequestClientIp(req, trustedProxies) ?? ""; if (!isLoopbackAddress(clientIp)) { return false; } const host = getHostName(req.headers?.host); const hostIsLocal = host === "localhost" || host === "127.0.0.1" || host === "::1"; const hostIsTailscaleServe = host.endsWith(".ts.net"); const hasForwarded = Boolean( req.headers?.["x-forwarded-for"] || req.headers?.["x-real-ip"] || req.headers?.["x-forwarded-host"], ); const remoteIsTrustedProxy = isTrustedProxyAddress(req.socket?.remoteAddress, trustedProxies); return (hostIsLocal || hostIsTailscaleServe) && (!hasForwarded || remoteIsTrustedProxy); } 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); } async function resolveVerifiedTailscaleUser(params: { req?: IncomingMessage; tailscaleWhois: TailscaleWhoisLookup; }): Promise<{ ok: true; user: TailscaleUser } | { ok: false; reason: string }> { const { req, tailscaleWhois } = params; const tailscaleUser = getTailscaleUser(req); if (!tailscaleUser) { return { ok: false, reason: "tailscale_user_missing" }; } if (!isTailscaleProxyRequest(req)) { return { ok: false, reason: "tailscale_proxy_missing" }; } const clientIp = resolveTailscaleClientIp(req); if (!clientIp) { return { ok: false, reason: "tailscale_whois_failed" }; } const whois = await tailscaleWhois(clientIp); if (!whois?.login) { return { ok: false, reason: "tailscale_whois_failed" }; } if (normalizeLogin(whois.login) !== normalizeLogin(tailscaleUser.login)) { return { ok: false, reason: "tailscale_user_mismatch" }; } return { ok: true, user: { login: whois.login, name: whois.name ?? tailscaleUser.name, profilePic: tailscaleUser.profilePic, }, }; } 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.OPENCLAW_GATEWAY_TOKEN ?? env.CLAWDBOT_GATEWAY_TOKEN ?? undefined; const password = authConfig.password ?? env.OPENCLAW_GATEWAY_PASSWORD ?? env.CLAWDBOT_GATEWAY_PASSWORD ?? undefined; const mode: ResolvedGatewayAuth["mode"] = authConfig.mode ?? (password ? "password" : "token"); 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) { if (auth.allowTailscale) { return; } throw new Error( "gateway auth mode is token, but no token was configured (set gateway.auth.token or OPENCLAW_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; trustedProxies?: string[]; tailscaleWhois?: TailscaleWhoisLookup; client?: GatewayClientInfo | null; }): Promise { const { auth, connectAuth, req, trustedProxies } = params; const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity; const localDirect = isLocalDirectRequest(req, trustedProxies); if (auth.mode === "token" && params.client?.id === GATEWAY_CLIENT_IDS.CONTROL_UI && req) { const session = readControlUiOauthSessionIdentityFromRequest(req); if (session) { return { ok: true, method: "control-ui-oauth", user: session.email ?? session.login ?? session.sub }; } } if (auth.allowTailscale && !localDirect) { const tailscaleCheck = await resolveVerifiedTailscaleUser({ req, tailscaleWhois, }); if (tailscaleCheck.ok) { return { ok: true, method: "tailscale", user: tailscaleCheck.user.login, }; } } if (auth.mode === "token") { if (!auth.token) { return { ok: false, reason: "token_missing_config" }; } if (!connectAuth?.token) { return { ok: false, reason: "token_missing" }; } if (!safeEqual(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" }; } return { ok: false, reason: "unauthorized" }; }