mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 00:02:20 +03:00
feat(cli): add HMAC-SHA256 machine token for localhost CLI auth
Generates a deterministic HMAC-SHA256(key=rawMachineId, msg=salt) token in src/lib/machineToken.ts. The management authz policy now accepts this token via x-omniroute-cli-token when Host is loopback, letting the local CLI process call management APIs without requiring a user login session. `omniroute config token` prints the token for manual use.
This commit is contained in:
@@ -12,6 +12,7 @@ Usage:
|
||||
omniroute config set <tool> [options] Write config for a tool
|
||||
omniroute config validate <tool> Validate config format without writing
|
||||
omniroute config tray <enable|disable> Enable/disable tray autostart on login
|
||||
omniroute config token Show the machine-derived CLI auth token
|
||||
|
||||
Options:
|
||||
--base-url <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") {
|
||||
|
||||
26
src/lib/machineToken.ts
Normal file
26
src/lib/machineToken.ts
Normal file
@@ -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 "";
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
22
tests/unit/lib/machineToken.test.ts
Normal file
22
tests/unit/lib/machineToken.test.ts
Normal file
@@ -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(""));
|
||||
});
|
||||
49
tests/unit/lib/managementCliToken.test.ts
Normal file
49
tests/unit/lib/managementCliToken.test.ts
Normal file
@@ -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<string, string>) {
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user