feat(gateway): add tailscale auth + pam
This commit is contained in:
@@ -17,6 +17,8 @@ import { forceFreePortAndWait } from "./ports.js";
|
||||
type GatewayRpcOpts = {
|
||||
url?: string;
|
||||
token?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
timeout?: string;
|
||||
expectFinal?: boolean;
|
||||
};
|
||||
@@ -25,6 +27,8 @@ const gatewayCallOpts = (cmd: Command) =>
|
||||
cmd
|
||||
.option("--url <url>", "Gateway WebSocket URL", "ws://127.0.0.1:18789")
|
||||
.option("--token <token>", "Gateway token (if required)")
|
||||
.option("--username <username>", "Gateway username (system auth)")
|
||||
.option("--password <password>", "Gateway password (password/system auth)")
|
||||
.option("--timeout <ms>", "Timeout in ms", "10000")
|
||||
.option("--expect-final", "Wait for final response (agent)", false);
|
||||
|
||||
@@ -36,6 +40,8 @@ const callGatewayCli = async (
|
||||
callGateway({
|
||||
url: opts.url,
|
||||
token: opts.token,
|
||||
username: opts.username,
|
||||
password: opts.password,
|
||||
method,
|
||||
params,
|
||||
expectFinal: Boolean(opts.expectFinal),
|
||||
@@ -57,6 +63,18 @@ export function registerGatewayCli(program: Command) {
|
||||
"--token <token>",
|
||||
"Shared token required in connect.params.auth.token (default: CLAWDIS_GATEWAY_TOKEN env if set)",
|
||||
)
|
||||
.option("--auth <mode>", 'Gateway auth mode ("token"|"password"|"system")')
|
||||
.option("--password <password>", "Password for auth mode=password")
|
||||
.option("--username <username>", "Default username for system auth")
|
||||
.option(
|
||||
"--tailscale <mode>",
|
||||
'Tailscale exposure mode ("off"|"serve"|"funnel")',
|
||||
)
|
||||
.option(
|
||||
"--tailscale-reset-on-exit",
|
||||
"Reset Tailscale serve/funnel configuration on shutdown",
|
||||
false,
|
||||
)
|
||||
.option("--verbose", "Verbose logging to stdout/stderr", false)
|
||||
.option(
|
||||
"--ws-log <style>",
|
||||
@@ -97,6 +115,34 @@ export function registerGatewayCli(program: Command) {
|
||||
if (opts.token) {
|
||||
process.env.CLAWDIS_GATEWAY_TOKEN = String(opts.token);
|
||||
}
|
||||
const authModeRaw = opts.auth ? String(opts.auth) : undefined;
|
||||
const authMode =
|
||||
authModeRaw === "token" ||
|
||||
authModeRaw === "password" ||
|
||||
authModeRaw === "system"
|
||||
? authModeRaw
|
||||
: null;
|
||||
if (authModeRaw && !authMode) {
|
||||
defaultRuntime.error(
|
||||
'Invalid --auth (use "token", "password", or "system")',
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const tailscaleRaw = opts.tailscale ? String(opts.tailscale) : undefined;
|
||||
const tailscaleMode =
|
||||
tailscaleRaw === "off" ||
|
||||
tailscaleRaw === "serve" ||
|
||||
tailscaleRaw === "funnel"
|
||||
? tailscaleRaw
|
||||
: null;
|
||||
if (tailscaleRaw && !tailscaleMode) {
|
||||
defaultRuntime.error(
|
||||
'Invalid --tailscale (use "off", "serve", or "funnel")',
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const cfg = loadConfig();
|
||||
const bindRaw = String(opts.bind ?? cfg.gateway?.bind ?? "loopback");
|
||||
const bind =
|
||||
@@ -157,7 +203,24 @@ export function registerGatewayCli(program: Command) {
|
||||
process.once("SIGINT", onSigint);
|
||||
|
||||
try {
|
||||
server = await startGatewayServer(port, { bind });
|
||||
server = await startGatewayServer(port, {
|
||||
bind,
|
||||
auth:
|
||||
authMode || opts.password || opts.username || authModeRaw
|
||||
? {
|
||||
mode: authMode ?? undefined,
|
||||
password: opts.password ? String(opts.password) : undefined,
|
||||
username: opts.username ? String(opts.username) : undefined,
|
||||
}
|
||||
: undefined,
|
||||
tailscale:
|
||||
tailscaleMode || opts.tailscaleResetOnExit
|
||||
? {
|
||||
mode: tailscaleMode ?? undefined,
|
||||
resetOnExit: Boolean(opts.tailscaleResetOnExit),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof GatewayLockError) {
|
||||
defaultRuntime.error(`Gateway failed to start: ${err.message}`);
|
||||
@@ -183,6 +246,18 @@ export function registerGatewayCli(program: Command) {
|
||||
"--token <token>",
|
||||
"Shared token required in connect.params.auth.token (default: CLAWDIS_GATEWAY_TOKEN env if set)",
|
||||
)
|
||||
.option("--auth <mode>", 'Gateway auth mode ("token"|"password"|"system")')
|
||||
.option("--password <password>", "Password for auth mode=password")
|
||||
.option("--username <username>", "Default username for system auth")
|
||||
.option(
|
||||
"--tailscale <mode>",
|
||||
'Tailscale exposure mode ("off"|"serve"|"funnel")',
|
||||
)
|
||||
.option(
|
||||
"--tailscale-reset-on-exit",
|
||||
"Reset Tailscale serve/funnel configuration on shutdown",
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
"--allow-unconfigured",
|
||||
"Allow gateway start without gateway.mode=local in config",
|
||||
@@ -267,6 +342,34 @@ export function registerGatewayCli(program: Command) {
|
||||
if (opts.token) {
|
||||
process.env.CLAWDIS_GATEWAY_TOKEN = String(opts.token);
|
||||
}
|
||||
const authModeRaw = opts.auth ? String(opts.auth) : undefined;
|
||||
const authMode =
|
||||
authModeRaw === "token" ||
|
||||
authModeRaw === "password" ||
|
||||
authModeRaw === "system"
|
||||
? authModeRaw
|
||||
: null;
|
||||
if (authModeRaw && !authMode) {
|
||||
defaultRuntime.error(
|
||||
'Invalid --auth (use "token", "password", or "system")',
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const tailscaleRaw = opts.tailscale ? String(opts.tailscale) : undefined;
|
||||
const tailscaleMode =
|
||||
tailscaleRaw === "off" ||
|
||||
tailscaleRaw === "serve" ||
|
||||
tailscaleRaw === "funnel"
|
||||
? tailscaleRaw
|
||||
: null;
|
||||
if (tailscaleRaw && !tailscaleMode) {
|
||||
defaultRuntime.error(
|
||||
'Invalid --tailscale (use "off", "serve", or "funnel")',
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const cfg = loadConfig();
|
||||
const configExists = fs.existsSync(CONFIG_PATH_CLAWDIS);
|
||||
const mode = cfg.gateway?.mode;
|
||||
@@ -344,7 +447,24 @@ export function registerGatewayCli(program: Command) {
|
||||
process.once("SIGINT", onSigint);
|
||||
|
||||
try {
|
||||
server = await startGatewayServer(port, { bind });
|
||||
server = await startGatewayServer(port, {
|
||||
bind,
|
||||
auth:
|
||||
authMode || opts.password || opts.username || authModeRaw
|
||||
? {
|
||||
mode: authMode ?? undefined,
|
||||
password: opts.password ? String(opts.password) : undefined,
|
||||
username: opts.username ? String(opts.username) : undefined,
|
||||
}
|
||||
: undefined,
|
||||
tailscale:
|
||||
tailscaleMode || opts.tailscaleResetOnExit
|
||||
? {
|
||||
mode: tailscaleMode ?? undefined,
|
||||
resetOnExit: Boolean(opts.tailscaleResetOnExit),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof GatewayLockError) {
|
||||
defaultRuntime.error(`Gateway failed to start: ${err.message}`);
|
||||
|
||||
@@ -106,6 +106,28 @@ export type GatewayControlUiConfig = {
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type GatewayAuthMode = "token" | "password" | "system";
|
||||
|
||||
export type GatewayAuthConfig = {
|
||||
/** Authentication mode for Gateway connections. Defaults to token when set. */
|
||||
mode?: GatewayAuthMode;
|
||||
/** Username for system auth (PAM). Defaults to current user. */
|
||||
username?: string;
|
||||
/** Shared password for password mode (consider env instead). */
|
||||
password?: string;
|
||||
/** Allow Tailscale identity headers when serve mode is enabled. */
|
||||
allowTailscale?: boolean;
|
||||
};
|
||||
|
||||
export type GatewayTailscaleMode = "off" | "serve" | "funnel";
|
||||
|
||||
export type GatewayTailscaleConfig = {
|
||||
/** Tailscale exposure mode for the Gateway control UI. */
|
||||
mode?: GatewayTailscaleMode;
|
||||
/** Reset serve/funnel configuration on shutdown. */
|
||||
resetOnExit?: boolean;
|
||||
};
|
||||
|
||||
export type GatewayConfig = {
|
||||
/**
|
||||
* Explicit gateway mode. When set to "remote", local gateway start is disabled.
|
||||
@@ -118,6 +140,8 @@ export type GatewayConfig = {
|
||||
*/
|
||||
bind?: BridgeBindMode;
|
||||
controlUi?: GatewayControlUiConfig;
|
||||
auth?: GatewayAuthConfig;
|
||||
tailscale?: GatewayTailscaleConfig;
|
||||
};
|
||||
|
||||
export type SkillConfig = {
|
||||
@@ -370,6 +394,28 @@ const ClawdisSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
auth: z
|
||||
.object({
|
||||
mode: z
|
||||
.union([
|
||||
z.literal("token"),
|
||||
z.literal("password"),
|
||||
z.literal("system"),
|
||||
])
|
||||
.optional(),
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
allowTailscale: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
tailscale: z
|
||||
.object({
|
||||
mode: z
|
||||
.union([z.literal("off"), z.literal("serve"), z.literal("funnel")])
|
||||
.optional(),
|
||||
resetOnExit: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
skillsLoad: z
|
||||
|
||||
@@ -5,6 +5,8 @@ import { PROTOCOL_VERSION } from "./protocol/index.js";
|
||||
export type CallGatewayOptions = {
|
||||
url?: string;
|
||||
token?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
expectFinal?: boolean;
|
||||
@@ -35,6 +37,8 @@ export async function callGateway<T = unknown>(
|
||||
const client = new GatewayClient({
|
||||
url: opts.url,
|
||||
token: opts.token,
|
||||
username: opts.username,
|
||||
password: opts.password,
|
||||
instanceId: opts.instanceId ?? randomUUID(),
|
||||
clientName: opts.clientName ?? "cli",
|
||||
clientVersion: opts.clientVersion ?? "dev",
|
||||
|
||||
@@ -19,6 +19,8 @@ type Pending = {
|
||||
export type GatewayClientOptions = {
|
||||
url?: string; // ws://127.0.0.1:18789
|
||||
token?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
instanceId?: string;
|
||||
clientName?: string;
|
||||
clientVersion?: string;
|
||||
@@ -81,6 +83,14 @@ export class GatewayClient {
|
||||
}
|
||||
|
||||
private sendConnect() {
|
||||
const auth =
|
||||
this.opts.token || this.opts.password || this.opts.username
|
||||
? {
|
||||
token: this.opts.token,
|
||||
username: this.opts.username,
|
||||
password: this.opts.password,
|
||||
}
|
||||
: undefined;
|
||||
const params: ConnectParams = {
|
||||
minProtocol: this.opts.minProtocol ?? PROTOCOL_VERSION,
|
||||
maxProtocol: this.opts.maxProtocol ?? PROTOCOL_VERSION,
|
||||
@@ -92,7 +102,7 @@ export class GatewayClient {
|
||||
instanceId: this.opts.instanceId,
|
||||
},
|
||||
caps: [],
|
||||
auth: this.opts.token ? { token: this.opts.token } : undefined,
|
||||
auth,
|
||||
};
|
||||
|
||||
void this.request<HelloOk>("connect", params)
|
||||
|
||||
@@ -77,6 +77,8 @@ export const ConnectParamsSchema = Type.Object(
|
||||
Type.Object(
|
||||
{
|
||||
token: Type.Optional(Type.String()),
|
||||
username: Type.Optional(Type.String()),
|
||||
password: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
|
||||
@@ -114,6 +114,7 @@ let testAllowFrom: string[] | undefined;
|
||||
let testCronStorePath: string | undefined;
|
||||
let testCronEnabled: boolean | undefined = false;
|
||||
let testGatewayBind: "auto" | "lan" | "tailnet" | "loopback" | undefined;
|
||||
let testGatewayAuth: Record<string, unknown> | undefined;
|
||||
const sessionStoreSaveDelayMs = vi.hoisted(() => ({ value: 0 }));
|
||||
vi.mock("../config/sessions.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../config/sessions.js")>(
|
||||
@@ -190,7 +191,12 @@ vi.mock("../config/config.js", () => {
|
||||
agent: { provider: "anthropic", model: "claude-opus-4-5" },
|
||||
session: { mainKey: "main", store: testSessionStorePath },
|
||||
},
|
||||
gateway: testGatewayBind ? { bind: testGatewayBind } : undefined,
|
||||
gateway: (() => {
|
||||
const gateway: Record<string, unknown> = {};
|
||||
if (testGatewayBind) gateway.bind = testGatewayBind;
|
||||
if (testGatewayAuth) gateway.auth = testGatewayAuth;
|
||||
return Object.keys(gateway).length > 0 ? gateway : undefined;
|
||||
})(),
|
||||
cron: (() => {
|
||||
const cron: Record<string, unknown> = {};
|
||||
if (typeof testCronEnabled === "boolean")
|
||||
@@ -244,6 +250,7 @@ beforeEach(async () => {
|
||||
sessionStoreSaveDelayMs.value = 0;
|
||||
testTailnetIPv4.value = undefined;
|
||||
testGatewayBind = undefined;
|
||||
testGatewayAuth = undefined;
|
||||
__resetModelCatalogCacheForTest();
|
||||
piAiMock.enabled = false;
|
||||
piAiMock.getModelsCalls.length = 0;
|
||||
@@ -335,6 +342,8 @@ async function connectReq(
|
||||
ws: WebSocket,
|
||||
opts?: {
|
||||
token?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
minProtocol?: number;
|
||||
maxProtocol?: number;
|
||||
client?: {
|
||||
@@ -362,7 +371,14 @@ async function connectReq(
|
||||
mode: "test",
|
||||
},
|
||||
caps: [],
|
||||
auth: opts?.token ? { token: opts.token } : undefined,
|
||||
auth:
|
||||
opts?.token || opts?.password || opts?.username
|
||||
? {
|
||||
token: opts?.token,
|
||||
username: opts?.username,
|
||||
password: opts?.password,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -1851,6 +1867,35 @@ describe("gateway server", () => {
|
||||
process.env.CLAWDIS_GATEWAY_TOKEN = prevToken;
|
||||
});
|
||||
|
||||
test("accepts password auth when configured", async () => {
|
||||
testGatewayAuth = { mode: "password", password: "secret" };
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||
|
||||
const res = await connectReq(ws, { password: "secret" });
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("rejects invalid password", async () => {
|
||||
testGatewayAuth = { mode: "password", password: "secret" };
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||
|
||||
const res = await connectReq(ws, { password: "wrong" });
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("unauthorized");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test(
|
||||
"closes silent handshakes after timeout",
|
||||
{ timeout: 15_000 },
|
||||
|
||||
@@ -181,3 +181,37 @@ export async function ensureFunnel(
|
||||
runtime.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export async function enableTailscaleServe(
|
||||
port: number,
|
||||
exec: typeof runExec = runExec,
|
||||
) {
|
||||
await exec("tailscale", ["serve", "--bg", "--yes", `${port}`], {
|
||||
maxBuffer: 200_000,
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function disableTailscaleServe(exec: typeof runExec = runExec) {
|
||||
await exec("tailscale", ["serve", "reset"], {
|
||||
maxBuffer: 200_000,
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function enableTailscaleFunnel(
|
||||
port: number,
|
||||
exec: typeof runExec = runExec,
|
||||
) {
|
||||
await exec("tailscale", ["funnel", "--bg", "--yes", `${port}`], {
|
||||
maxBuffer: 200_000,
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function disableTailscaleFunnel(exec: typeof runExec = runExec) {
|
||||
await exec("tailscale", ["funnel", "reset"], {
|
||||
maxBuffer: 200_000,
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user