mirror of https://github.com/openclaw/openclaw.git
278 lines
7.9 KiB
TypeScript
278 lines
7.9 KiB
TypeScript
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 {
|
|
isLoopbackAddress,
|
|
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";
|
|
user?: string;
|
|
reason?: string;
|
|
};
|
|
|
|
type ConnectAuth = {
|
|
token?: string;
|
|
password?: string;
|
|
};
|
|
|
|
type TailscaleUser = {
|
|
login: string;
|
|
name: string;
|
|
profilePic?: string;
|
|
};
|
|
|
|
type TailscaleWhoisLookup = (ip: string) => Promise<TailscaleWhoisIdentity | null>;
|
|
|
|
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 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;
|
|
}): Promise<GatewayAuthResult> {
|
|
const { auth, connectAuth, req, trustedProxies } = params;
|
|
const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity;
|
|
const localDirect = isLocalDirectRequest(req, trustedProxies);
|
|
|
|
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" };
|
|
}
|