211 lines
5.8 KiB
TypeScript
211 lines
5.8 KiB
TypeScript
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<GatewayAuthResult> {
|
|
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" };
|
|
}
|