diff --git a/bin/cli/commands/config.mjs b/bin/cli/commands/config.mjs index 23fce1c3b0..adce858f30 100644 --- a/bin/cli/commands/config.mjs +++ b/bin/cli/commands/config.mjs @@ -12,6 +12,7 @@ Usage: omniroute config set [options] Write config for a tool omniroute config validate Validate config format without writing omniroute config tray Enable/disable tray autostart on login + omniroute config token Show the machine-derived CLI auth token Options: --base-url OmniRoute API base URL (default: http://localhost:20128/v1) @@ -177,6 +178,24 @@ export async function runConfigCommand(argv) { return 0; } + if (subcommand === "token") { + const { getMachineTokenSync } = await import("../../../src/lib/machineToken.ts"); + const token = getMachineTokenSync(); + if (!token) { + printError("Could not derive machine token (machine-id unavailable)."); + return 1; + } + if (hasFlag(flags, "json")) { + console.log(JSON.stringify({ token, header: "x-omniroute-cli-token" })); + } else { + printHeading("CLI Machine Token"); + console.log(` Header: x-omniroute-cli-token`); + console.log(` Value: ${token}`); + console.log(`\n Use this token to authenticate management API calls from localhost.`); + } + return 0; + } + if (subcommand === "tray") { const action = positionals[1]; if (action === "enable") { diff --git a/src/lib/machineToken.ts b/src/lib/machineToken.ts new file mode 100644 index 0000000000..5ea3c76f01 --- /dev/null +++ b/src/lib/machineToken.ts @@ -0,0 +1,26 @@ +import { createHmac } from "node:crypto"; +import nodeMachineId from "node-machine-id"; + +const { machineIdSync } = nodeMachineId; + +const DEFAULT_SALT = "omniroute-cli-auth"; + +function deriveToken(rawId: string, salt: string): string { + return createHmac("sha256", rawId).update(salt).digest("hex").slice(0, 32); +} + +let cached: string | null = null; + +export function getMachineTokenSync(salt = DEFAULT_SALT): string { + try { + // machineIdSync(true) returns the original unhashed hardware ID. + const rawId = machineIdSync(true); + if (salt === DEFAULT_SALT) { + cached ??= deriveToken(rawId, salt); + return cached; + } + return deriveToken(rawId, salt); + } catch { + return ""; + } +} diff --git a/src/server/authz/headers.ts b/src/server/authz/headers.ts index 906befc39a..32d9cb3323 100644 --- a/src/server/authz/headers.ts +++ b/src/server/authz/headers.ts @@ -21,6 +21,9 @@ export const AUTHZ_HEADER_AUTH_ID = "x-omniroute-auth-id"; export const AUTHZ_HEADER_AUTH_LABEL = "x-omniroute-auth-label"; export const AUTHZ_HEADER_AUTH_SCOPES = "x-omniroute-auth-scopes"; +/** CLI sends this header so the local process can call management APIs without login. */ +export const CLI_TOKEN_HEADER = "x-omniroute-cli-token"; + /** * Headers the pipeline must NEVER trust on incoming requests. They are * stripped before route classification to prevent header-spoofing attacks. diff --git a/src/server/authz/policies/management.ts b/src/server/authz/policies/management.ts index e876d0a9ab..e14abb5df4 100644 --- a/src/server/authz/policies/management.ts +++ b/src/server/authz/policies/management.ts @@ -1,9 +1,28 @@ import { isModelSyncInternalRequest } from "../../../shared/services/modelSyncScheduler"; import { isAuthRequired, isDashboardSessionAuthenticated } from "../../../shared/utils/apiAuth"; +import { getMachineTokenSync } from "../../../lib/machineToken"; import type { AuthOutcome, PolicyContext, RoutePolicy } from "../context"; import { allow, reject } from "../context"; +import { CLI_TOKEN_HEADER } from "../headers"; const MODEL_SYNC_MANAGEMENT_PATH = /^\/api\/providers\/[^/]+\/(sync-models|models)$/; +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]); + +function isLoopbackRequest(headers: Headers): boolean { + const host = (headers.get("host") ?? "") + .split(":")[0] + .replace(/^\[|\]$/g, "") + .toLowerCase(); + return LOOPBACK_HOSTS.has(host); +} + +function hasValidCliToken(headers: Headers): boolean { + if (!isLoopbackRequest(headers)) return false; + const provided = headers.get(CLI_TOKEN_HEADER); + if (!provided) return false; + const expected = getMachineTokenSync(); + return expected !== "" && provided === expected; +} function hasBearerToken(headers: Headers): boolean { const authHeader = headers.get("authorization") ?? headers.get("Authorization"); @@ -26,6 +45,10 @@ export const managementPolicy: RoutePolicy = { return allow({ kind: "management_key", id: "model-sync", label: "internal-model-sync" }); } + if (hasValidCliToken(ctx.request.headers)) { + return allow({ kind: "management_key", id: "cli", label: "local-cli-token" }); + } + if (await isDashboardSessionAuthenticated(ctx.request)) { return allow({ kind: "dashboard_session", id: "dashboard" }); } diff --git a/tests/unit/lib/machineToken.test.ts b/tests/unit/lib/machineToken.test.ts new file mode 100644 index 0000000000..4921456b91 --- /dev/null +++ b/tests/unit/lib/machineToken.test.ts @@ -0,0 +1,22 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { getMachineTokenSync } from "../../../src/lib/machineToken.ts"; + +test("getMachineTokenSync returns a 32-character hex string", () => { + const token = getMachineTokenSync(); + assert.match(token, /^[0-9a-f]{32}$/, "token must be 32 lowercase hex chars"); +}); + +test("getMachineTokenSync is deterministic", () => { + assert.equal(getMachineTokenSync(), getMachineTokenSync()); +}); + +test("getMachineTokenSync produces different values for different salts", () => { + const t1 = getMachineTokenSync("salt-a"); + const t2 = getMachineTokenSync("salt-b"); + assert.notEqual(t1, t2); +}); + +test("getMachineTokenSync with empty string salt does not throw", () => { + assert.doesNotThrow(() => getMachineTokenSync("")); +}); diff --git a/tests/unit/lib/managementCliToken.test.ts b/tests/unit/lib/managementCliToken.test.ts new file mode 100644 index 0000000000..0f22378b36 --- /dev/null +++ b/tests/unit/lib/managementCliToken.test.ts @@ -0,0 +1,49 @@ +import { test, mock } from "node:test"; +import assert from "node:assert/strict"; +import { getMachineTokenSync } from "../../../src/lib/machineToken.ts"; +import { managementPolicy } from "../../../src/server/authz/policies/management.ts"; +import { CLI_TOKEN_HEADER } from "../../../src/server/authz/headers.ts"; + +function makeCtx(headers: Record) { + return { + request: { + method: "GET", + headers: new Headers(headers), + cookies: { get: () => undefined }, + nextUrl: { pathname: "/api/settings" }, + url: "http://localhost:20128/api/settings", + }, + classification: { + routeClass: "MANAGEMENT" as const, + normalizedPath: "/api/settings", + method: "GET", + }, + requestId: "test-req", + }; +} + +test("management policy allows valid CLI token from localhost", async () => { + const token = getMachineTokenSync(); + const ctx = makeCtx({ host: "localhost", [CLI_TOKEN_HEADER]: token }); + const outcome = await managementPolicy.evaluate(ctx); + assert.equal(outcome.allow, true); + if (outcome.allow) { + assert.equal(outcome.subject.id, "cli"); + } +}); + +test("management policy rejects valid token from non-localhost", async () => { + const token = getMachineTokenSync(); + const ctx = makeCtx({ host: "192.168.1.100", [CLI_TOKEN_HEADER]: token }); + const outcome = await managementPolicy.evaluate(ctx); + assert.equal(outcome.allow, false); +}); + +test("management policy rejects wrong CLI token from localhost", async () => { + const ctx = makeCtx({ + host: "localhost", + [CLI_TOKEN_HEADER]: "deadbeefdeadbeefdeadbeefdeadbeef", + }); + const outcome = await managementPolicy.evaluate(ctx); + assert.equal(outcome.allow, false); +});