fix(gateway): avoid crash in handshake auth
parent
fedb24caf1
commit
d69064f364
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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<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: "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" };
|
||||||
|
}
|
||||||
|
|
@ -73,6 +73,7 @@ import {
|
||||||
requestNodePairing,
|
requestNodePairing,
|
||||||
verifyNodeToken,
|
verifyNodeToken,
|
||||||
} from "../infra/node-pairing.js";
|
} from "../infra/node-pairing.js";
|
||||||
|
import { getPamAvailability } from "../infra/pam.js";
|
||||||
import { ensureClawdisCliOnPath } from "../infra/path-env.js";
|
import { ensureClawdisCliOnPath } from "../infra/path-env.js";
|
||||||
import {
|
import {
|
||||||
enqueueSystemEvent,
|
enqueueSystemEvent,
|
||||||
|
|
@ -87,7 +88,13 @@ import {
|
||||||
pickPrimaryTailnetIPv4,
|
pickPrimaryTailnetIPv4,
|
||||||
pickPrimaryTailnetIPv6,
|
pickPrimaryTailnetIPv6,
|
||||||
} from "../infra/tailnet.js";
|
} from "../infra/tailnet.js";
|
||||||
import { getTailnetHostname } from "../infra/tailscale.js";
|
import {
|
||||||
|
disableTailscaleFunnel,
|
||||||
|
disableTailscaleServe,
|
||||||
|
enableTailscaleFunnel,
|
||||||
|
enableTailscaleServe,
|
||||||
|
getTailnetHostname,
|
||||||
|
} from "../infra/tailscale.js";
|
||||||
import {
|
import {
|
||||||
defaultVoiceWakeTriggers,
|
defaultVoiceWakeTriggers,
|
||||||
loadVoiceWakeConfig,
|
loadVoiceWakeConfig,
|
||||||
|
|
@ -115,6 +122,11 @@ import { startWebLoginWithQr, waitForWebLogin } from "../web/login-qr.js";
|
||||||
import { sendMessageWhatsApp } from "../web/outbound.js";
|
import { sendMessageWhatsApp } from "../web/outbound.js";
|
||||||
import { requestReplyHeartbeatNow } from "../web/reply-heartbeat-wake.js";
|
import { requestReplyHeartbeatNow } from "../web/reply-heartbeat-wake.js";
|
||||||
import { getWebAuthAgeMs, logoutWeb, readWebSelfId } from "../web/session.js";
|
import { getWebAuthAgeMs, logoutWeb, readWebSelfId } from "../web/session.js";
|
||||||
|
import {
|
||||||
|
assertGatewayAuthConfigured,
|
||||||
|
authorizeGatewayConnect,
|
||||||
|
type ResolvedGatewayAuth,
|
||||||
|
} from "./auth.js";
|
||||||
import { buildMessageWithAttachments } from "./chat-attachments.js";
|
import { buildMessageWithAttachments } from "./chat-attachments.js";
|
||||||
import { handleControlUiHttpRequest } from "./control-ui.js";
|
import { handleControlUiHttpRequest } from "./control-ui.js";
|
||||||
|
|
||||||
|
|
@ -420,6 +432,14 @@ export type GatewayServerOptions = {
|
||||||
* Default: config `gateway.controlUi.enabled` (or true when absent).
|
* Default: config `gateway.controlUi.enabled` (or true when absent).
|
||||||
*/
|
*/
|
||||||
controlUiEnabled?: boolean;
|
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.
|
* Test-only: allow canvas host startup even when NODE_ENV/VITEST would disable it.
|
||||||
*/
|
*/
|
||||||
|
|
@ -1097,12 +1117,52 @@ export async function startGatewayServer(
|
||||||
}
|
}
|
||||||
const controlUiEnabled =
|
const controlUiEnabled =
|
||||||
opts.controlUiEnabled ?? cfgAtStart.gateway?.controlUi?.enabled ?? true;
|
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 =
|
const canvasHostEnabled =
|
||||||
process.env.CLAWDIS_SKIP_CANVAS_HOST !== "1" &&
|
process.env.CLAWDIS_SKIP_CANVAS_HOST !== "1" &&
|
||||||
cfgAtStart.canvasHost?.enabled !== false;
|
cfgAtStart.canvasHost?.enabled !== false;
|
||||||
if (!isLoopbackHost(bindHost) && !getGatewayToken()) {
|
const pamAvailability = await getPamAvailability();
|
||||||
|
assertGatewayAuthConfigured(resolvedAuth, pamAvailability);
|
||||||
|
if (tailscaleMode === "funnel" && authMode === "none") {
|
||||||
throw new Error(
|
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()
|
.start()
|
||||||
.catch((err) => logError(`cron failed to start: ${String(err)}`));
|
.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 client: Client | null = null;
|
||||||
let closed = false;
|
let closed = false;
|
||||||
const connId = randomUUID();
|
const connId = randomUUID();
|
||||||
|
|
@ -2632,7 +2692,7 @@ export async function startGatewayServer(
|
||||||
? bridgeHost
|
? bridgeHost
|
||||||
: undefined;
|
: undefined;
|
||||||
const canvasHostUrl = deriveCanvasHostUrl(
|
const canvasHostUrl = deriveCanvasHostUrl(
|
||||||
req,
|
upgradeReq,
|
||||||
canvasHostPortForWs,
|
canvasHostPortForWs,
|
||||||
canvasHostServer ? canvasHostOverride : undefined,
|
canvasHostServer ? canvasHostOverride : undefined,
|
||||||
);
|
);
|
||||||
|
|
@ -2746,8 +2806,8 @@ export async function startGatewayServer(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const req = parsed as RequestFrame;
|
const frame = parsed as RequestFrame;
|
||||||
const connectParams = req.params as ConnectParams;
|
const connectParams = frame.params as ConnectParams;
|
||||||
|
|
||||||
// protocol negotiation
|
// protocol negotiation
|
||||||
const { minProtocol, maxProtocol } = connectParams;
|
const { minProtocol, maxProtocol } = connectParams;
|
||||||
|
|
@ -2760,7 +2820,7 @@ export async function startGatewayServer(
|
||||||
);
|
);
|
||||||
send({
|
send({
|
||||||
type: "res",
|
type: "res",
|
||||||
id: req.id,
|
id: frame.id,
|
||||||
ok: false,
|
ok: false,
|
||||||
error: errorShape(
|
error: errorShape(
|
||||||
ErrorCodes.INVALID_REQUEST,
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
|
@ -2775,15 +2835,18 @@ export async function startGatewayServer(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// token auth if required
|
const authResult = await authorizeGatewayConnect({
|
||||||
const token = getGatewayToken();
|
auth: resolvedAuth,
|
||||||
if (token && connectParams.auth?.token !== token) {
|
connectAuth: connectParams.auth,
|
||||||
|
req: upgradeReq,
|
||||||
|
});
|
||||||
|
if (!authResult.ok) {
|
||||||
logWarn(
|
logWarn(
|
||||||
`[gws] unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${connectParams.client.name} ${connectParams.client.mode} v${connectParams.client.version}`,
|
`[gws] unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${connectParams.client.name} ${connectParams.client.mode} v${connectParams.client.version}`,
|
||||||
);
|
);
|
||||||
send({
|
send({
|
||||||
type: "res",
|
type: "res",
|
||||||
id: req.id,
|
id: frame.id,
|
||||||
ok: false,
|
ok: false,
|
||||||
error: errorShape(ErrorCodes.INVALID_REQUEST, "unauthorized"),
|
error: errorShape(ErrorCodes.INVALID_REQUEST, "unauthorized"),
|
||||||
});
|
});
|
||||||
|
|
@ -2791,6 +2854,7 @@ export async function startGatewayServer(
|
||||||
close();
|
close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const authMethod = authResult.method ?? "none";
|
||||||
|
|
||||||
const shouldTrackPresence = connectParams.client.mode !== "cli";
|
const shouldTrackPresence = connectParams.client.mode !== "cli";
|
||||||
const presenceKey = shouldTrackPresence
|
const presenceKey = shouldTrackPresence
|
||||||
|
|
@ -2804,7 +2868,7 @@ export async function startGatewayServer(
|
||||||
mode: connectParams.client.mode,
|
mode: connectParams.client.mode,
|
||||||
instanceId: connectParams.client.instanceId,
|
instanceId: connectParams.client.instanceId,
|
||||||
platform: connectParams.client.platform,
|
platform: connectParams.client.platform,
|
||||||
token: connectParams.auth?.token ? "set" : "none",
|
auth: authMethod,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isWebchatConnect(connectParams)) {
|
if (isWebchatConnect(connectParams)) {
|
||||||
|
|
@ -2866,7 +2930,7 @@ export async function startGatewayServer(
|
||||||
stateVersion: snapshot.stateVersion.presence,
|
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);
|
clients.add(client);
|
||||||
void refreshHealthSnapshot({ probe: true }).catch((err) =>
|
void refreshHealthSnapshot({ probe: true }).catch((err) =>
|
||||||
|
|
@ -4891,6 +4955,43 @@ export async function startGatewayServer(
|
||||||
`gateway listening on ws://${bindHost}:${port} (PID ${process.pid})`,
|
`gateway listening on ws://${bindHost}:${port} (PID ${process.pid})`,
|
||||||
);
|
);
|
||||||
defaultRuntime.log(`gateway log file: ${getResolvedLoggerSettings().file}`);
|
defaultRuntime.log(`gateway log file: ${getResolvedLoggerSettings().file}`);
|
||||||
|
let tailscaleCleanup: (() => Promise<void>) | 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).
|
// Start clawd browser control server (unless disabled via config).
|
||||||
void startBrowserControlServerIfEnabled().catch((err) => {
|
void startBrowserControlServerIfEnabled().catch((err) => {
|
||||||
|
|
@ -4916,6 +5017,9 @@ export async function startGatewayServer(
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (tailscaleCleanup) {
|
||||||
|
await tailscaleCleanup();
|
||||||
|
}
|
||||||
if (canvasHost) {
|
if (canvasHost) {
|
||||||
try {
|
try {
|
||||||
await canvasHost.close();
|
await canvasHost.close();
|
||||||
|
|
|
||||||
|
|
@ -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<void> {
|
||||||
|
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<PamAvailability> {
|
||||||
|
await loadPam();
|
||||||
|
return pamAuth
|
||||||
|
? { available: true }
|
||||||
|
: { available: false, error: pamError ?? undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPamCredentials(
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
await loadPam();
|
||||||
|
const auth = pamAuth;
|
||||||
|
if (!auth) return false;
|
||||||
|
return await new Promise<boolean>((resolve) => {
|
||||||
|
auth(username, password, (err) => resolve(!err));
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue