feat: unify device auth + pairing
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
resolveStateDir,
|
||||
} from "../config/config.js";
|
||||
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
|
||||
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
|
||||
import {
|
||||
GATEWAY_CLIENT_MODES,
|
||||
GATEWAY_CLIENT_NAMES,
|
||||
@@ -186,6 +187,9 @@ export async function callGateway<T = unknown>(opts: CallGatewayOptions): Promis
|
||||
clientVersion: opts.clientVersion ?? "dev",
|
||||
platform: opts.platform,
|
||||
mode: opts.mode ?? GATEWAY_CLIENT_MODES.CLI,
|
||||
role: "operator",
|
||||
scopes: ["operator.admin", "operator.approvals", "operator.pairing"],
|
||||
deviceIdentity: loadOrCreateDeviceIdentity(),
|
||||
minProtocol: opts.minProtocol ?? PROTOCOL_VERSION,
|
||||
maxProtocol: opts.maxProtocol ?? PROTOCOL_VERSION,
|
||||
onHelloOk: async () => {
|
||||
|
||||
@@ -2,12 +2,15 @@ import { randomUUID } from "node:crypto";
|
||||
import { WebSocket } from "ws";
|
||||
import { rawDataToString } from "../infra/ws.js";
|
||||
import { logDebug, logError } from "../logger.js";
|
||||
import type { DeviceIdentity } from "../infra/device-identity.js";
|
||||
import { publicKeyRawBase64UrlFromPem, signDevicePayload } from "../infra/device-identity.js";
|
||||
import {
|
||||
GATEWAY_CLIENT_MODES,
|
||||
GATEWAY_CLIENT_NAMES,
|
||||
type GatewayClientMode,
|
||||
type GatewayClientName,
|
||||
} from "../utils/message-channel.js";
|
||||
import { buildDeviceAuthPayload } from "./device-auth.js";
|
||||
import {
|
||||
type ConnectParams,
|
||||
type EventFrame,
|
||||
@@ -35,6 +38,9 @@ export type GatewayClientOptions = {
|
||||
clientVersion?: string;
|
||||
platform?: string;
|
||||
mode?: GatewayClientMode;
|
||||
role?: string;
|
||||
scopes?: string[];
|
||||
deviceIdentity?: DeviceIdentity;
|
||||
minProtocol?: number;
|
||||
maxProtocol?: number;
|
||||
onEvent?: (evt: EventFrame) => void;
|
||||
@@ -110,6 +116,28 @@ export class GatewayClient {
|
||||
password: this.opts.password,
|
||||
}
|
||||
: undefined;
|
||||
const signedAtMs = Date.now();
|
||||
const role = this.opts.role ?? "operator";
|
||||
const scopes = this.opts.scopes ?? ["operator.admin"];
|
||||
const device = (() => {
|
||||
if (!this.opts.deviceIdentity) return undefined;
|
||||
const payload = buildDeviceAuthPayload({
|
||||
deviceId: this.opts.deviceIdentity.deviceId,
|
||||
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
|
||||
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND,
|
||||
role,
|
||||
scopes,
|
||||
signedAtMs,
|
||||
token: this.opts.token ?? null,
|
||||
});
|
||||
const signature = signDevicePayload(this.opts.deviceIdentity.privateKeyPem, payload);
|
||||
return {
|
||||
id: this.opts.deviceIdentity.deviceId,
|
||||
publicKey: publicKeyRawBase64UrlFromPem(this.opts.deviceIdentity.publicKeyPem),
|
||||
signature,
|
||||
signedAt: signedAtMs,
|
||||
};
|
||||
})();
|
||||
const params: ConnectParams = {
|
||||
minProtocol: this.opts.minProtocol ?? PROTOCOL_VERSION,
|
||||
maxProtocol: this.opts.maxProtocol ?? PROTOCOL_VERSION,
|
||||
@@ -123,6 +151,9 @@ export class GatewayClient {
|
||||
},
|
||||
caps: [],
|
||||
auth,
|
||||
role,
|
||||
scopes,
|
||||
device,
|
||||
};
|
||||
|
||||
void this.request<HelloOk>("connect", params)
|
||||
|
||||
24
src/gateway/device-auth.ts
Normal file
24
src/gateway/device-auth.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export type DeviceAuthPayloadParams = {
|
||||
deviceId: string;
|
||||
clientId: string;
|
||||
clientMode: string;
|
||||
role: string;
|
||||
scopes: string[];
|
||||
signedAtMs: number;
|
||||
token?: string | null;
|
||||
};
|
||||
|
||||
export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string {
|
||||
const scopes = params.scopes.join(",");
|
||||
const token = params.token ?? "";
|
||||
return [
|
||||
"v1",
|
||||
params.deviceId,
|
||||
params.clientId,
|
||||
params.clientMode,
|
||||
params.role,
|
||||
scopes,
|
||||
String(params.signedAtMs),
|
||||
token,
|
||||
].join("|");
|
||||
}
|
||||
74
src/gateway/exec-approval-manager.ts
Normal file
74
src/gateway/exec-approval-manager.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import type { ExecApprovalDecision } from "../infra/exec-approvals.js";
|
||||
|
||||
export type ExecApprovalRequestPayload = {
|
||||
command: string;
|
||||
cwd?: string | null;
|
||||
host?: string | null;
|
||||
security?: string | null;
|
||||
ask?: string | null;
|
||||
agentId?: string | null;
|
||||
resolvedPath?: string | null;
|
||||
sessionKey?: string | null;
|
||||
};
|
||||
|
||||
export type ExecApprovalRecord = {
|
||||
id: string;
|
||||
request: ExecApprovalRequestPayload;
|
||||
createdAtMs: number;
|
||||
expiresAtMs: number;
|
||||
resolvedAtMs?: number;
|
||||
decision?: ExecApprovalDecision;
|
||||
resolvedBy?: string | null;
|
||||
};
|
||||
|
||||
type PendingEntry = {
|
||||
record: ExecApprovalRecord;
|
||||
resolve: (decision: ExecApprovalDecision) => void;
|
||||
reject: (err: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
export class ExecApprovalManager {
|
||||
private pending = new Map<string, PendingEntry>();
|
||||
|
||||
create(request: ExecApprovalRequestPayload, timeoutMs: number): ExecApprovalRecord {
|
||||
const now = Date.now();
|
||||
const id = randomUUID();
|
||||
const record: ExecApprovalRecord = {
|
||||
id,
|
||||
request,
|
||||
createdAtMs: now,
|
||||
expiresAtMs: now + timeoutMs,
|
||||
};
|
||||
return record;
|
||||
}
|
||||
|
||||
async waitForDecision(record: ExecApprovalRecord, timeoutMs: number): Promise<ExecApprovalDecision> {
|
||||
return await new Promise<ExecApprovalDecision>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(record.id);
|
||||
resolve("deny");
|
||||
}, timeoutMs);
|
||||
this.pending.set(record.id, { record, resolve, reject, timer });
|
||||
});
|
||||
}
|
||||
|
||||
resolve(recordId: string, decision: ExecApprovalDecision, resolvedBy?: string | null): boolean {
|
||||
const pending = this.pending.get(recordId);
|
||||
if (!pending) return false;
|
||||
clearTimeout(pending.timer);
|
||||
pending.record.resolvedAtMs = Date.now();
|
||||
pending.record.decision = decision;
|
||||
pending.record.resolvedBy = resolvedBy ?? null;
|
||||
this.pending.delete(recordId);
|
||||
pending.resolve(decision);
|
||||
return true;
|
||||
}
|
||||
|
||||
getSnapshot(recordId: string): ExecApprovalRecord | null {
|
||||
const entry = this.pending.get(recordId);
|
||||
return entry?.record ?? null;
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,12 @@ import {
|
||||
CronStatusParamsSchema,
|
||||
type CronUpdateParams,
|
||||
CronUpdateParamsSchema,
|
||||
type DevicePairApproveParams,
|
||||
DevicePairApproveParamsSchema,
|
||||
type DevicePairListParams,
|
||||
DevicePairListParamsSchema,
|
||||
type DevicePairRejectParams,
|
||||
DevicePairRejectParamsSchema,
|
||||
type ExecApprovalsGetParams,
|
||||
ExecApprovalsGetParamsSchema,
|
||||
type ExecApprovalsNodeGetParams,
|
||||
@@ -65,6 +71,10 @@ import {
|
||||
type ExecApprovalsSetParams,
|
||||
ExecApprovalsSetParamsSchema,
|
||||
type ExecApprovalsSnapshot,
|
||||
type ExecApprovalRequestParams,
|
||||
ExecApprovalRequestParamsSchema,
|
||||
type ExecApprovalResolveParams,
|
||||
ExecApprovalResolveParamsSchema,
|
||||
ErrorCodes,
|
||||
type ErrorShape,
|
||||
ErrorShapeSchema,
|
||||
@@ -239,12 +249,27 @@ export const validateCronUpdateParams = ajv.compile<CronUpdateParams>(CronUpdate
|
||||
export const validateCronRemoveParams = ajv.compile<CronRemoveParams>(CronRemoveParamsSchema);
|
||||
export const validateCronRunParams = ajv.compile<CronRunParams>(CronRunParamsSchema);
|
||||
export const validateCronRunsParams = ajv.compile<CronRunsParams>(CronRunsParamsSchema);
|
||||
export const validateDevicePairListParams = ajv.compile<DevicePairListParams>(
|
||||
DevicePairListParamsSchema,
|
||||
);
|
||||
export const validateDevicePairApproveParams = ajv.compile<DevicePairApproveParams>(
|
||||
DevicePairApproveParamsSchema,
|
||||
);
|
||||
export const validateDevicePairRejectParams = ajv.compile<DevicePairRejectParams>(
|
||||
DevicePairRejectParamsSchema,
|
||||
);
|
||||
export const validateExecApprovalsGetParams = ajv.compile<ExecApprovalsGetParams>(
|
||||
ExecApprovalsGetParamsSchema,
|
||||
);
|
||||
export const validateExecApprovalsSetParams = ajv.compile<ExecApprovalsSetParams>(
|
||||
ExecApprovalsSetParamsSchema,
|
||||
);
|
||||
export const validateExecApprovalRequestParams = ajv.compile<ExecApprovalRequestParams>(
|
||||
ExecApprovalRequestParamsSchema,
|
||||
);
|
||||
export const validateExecApprovalResolveParams = ajv.compile<ExecApprovalResolveParams>(
|
||||
ExecApprovalResolveParamsSchema,
|
||||
);
|
||||
export const validateExecApprovalsNodeGetParams = ajv.compile<ExecApprovalsNodeGetParams>(
|
||||
ExecApprovalsNodeGetParamsSchema,
|
||||
);
|
||||
@@ -364,6 +389,9 @@ export type {
|
||||
NodePairRequestParams,
|
||||
NodePairListParams,
|
||||
NodePairApproveParams,
|
||||
DevicePairListParams,
|
||||
DevicePairApproveParams,
|
||||
DevicePairRejectParams,
|
||||
ConfigGetParams,
|
||||
ConfigSetParams,
|
||||
ConfigApplyParams,
|
||||
|
||||
@@ -5,6 +5,7 @@ export * from "./schema/config.js";
|
||||
export * from "./schema/cron.js";
|
||||
export * from "./schema/error-codes.js";
|
||||
export * from "./schema/exec-approvals.js";
|
||||
export * from "./schema/devices.js";
|
||||
export * from "./schema/frames.js";
|
||||
export * from "./schema/logs-chat.js";
|
||||
export * from "./schema/nodes.js";
|
||||
|
||||
44
src/gateway/protocol/schema/devices.ts
Normal file
44
src/gateway/protocol/schema/devices.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
import { NonEmptyString } from "./primitives.js";
|
||||
|
||||
export const DevicePairListParamsSchema = Type.Object({}, { additionalProperties: false });
|
||||
|
||||
export const DevicePairApproveParamsSchema = Type.Object(
|
||||
{ requestId: NonEmptyString },
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const DevicePairRejectParamsSchema = Type.Object(
|
||||
{ requestId: NonEmptyString },
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const DevicePairRequestedEventSchema = Type.Object(
|
||||
{
|
||||
requestId: NonEmptyString,
|
||||
deviceId: NonEmptyString,
|
||||
publicKey: NonEmptyString,
|
||||
displayName: Type.Optional(NonEmptyString),
|
||||
platform: Type.Optional(NonEmptyString),
|
||||
clientId: Type.Optional(NonEmptyString),
|
||||
clientMode: Type.Optional(NonEmptyString),
|
||||
role: Type.Optional(NonEmptyString),
|
||||
scopes: Type.Optional(Type.Array(NonEmptyString)),
|
||||
remoteIp: Type.Optional(NonEmptyString),
|
||||
silent: Type.Optional(Type.Boolean()),
|
||||
isRepair: Type.Optional(Type.Boolean()),
|
||||
ts: Type.Integer({ minimum: 0 }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const DevicePairResolvedEventSchema = Type.Object(
|
||||
{
|
||||
requestId: NonEmptyString,
|
||||
deviceId: NonEmptyString,
|
||||
decision: NonEmptyString,
|
||||
ts: Type.Integer({ minimum: 0 }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
@@ -2,6 +2,7 @@ import type { ErrorShape } from "./types.js";
|
||||
|
||||
export const ErrorCodes = {
|
||||
NOT_LINKED: "NOT_LINKED",
|
||||
NOT_PAIRED: "NOT_PAIRED",
|
||||
AGENT_TIMEOUT: "AGENT_TIMEOUT",
|
||||
INVALID_REQUEST: "INVALID_REQUEST",
|
||||
UNAVAILABLE: "UNAVAILABLE",
|
||||
|
||||
@@ -86,3 +86,26 @@ export const ExecApprovalsNodeSetParamsSchema = Type.Object(
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ExecApprovalRequestParamsSchema = Type.Object(
|
||||
{
|
||||
command: NonEmptyString,
|
||||
cwd: Type.Optional(Type.String()),
|
||||
host: Type.Optional(Type.String()),
|
||||
security: Type.Optional(Type.String()),
|
||||
ask: Type.Optional(Type.String()),
|
||||
agentId: Type.Optional(Type.String()),
|
||||
resolvedPath: Type.Optional(Type.String()),
|
||||
sessionKey: Type.Optional(Type.String()),
|
||||
timeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ExecApprovalResolveParamsSchema = Type.Object(
|
||||
{
|
||||
id: NonEmptyString,
|
||||
decision: NonEmptyString,
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
@@ -35,6 +35,19 @@ export const ConnectParamsSchema = Type.Object(
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
caps: Type.Optional(Type.Array(NonEmptyString, { default: [] })),
|
||||
role: Type.Optional(NonEmptyString),
|
||||
scopes: Type.Optional(Type.Array(NonEmptyString)),
|
||||
device: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
id: NonEmptyString,
|
||||
publicKey: NonEmptyString,
|
||||
signature: NonEmptyString,
|
||||
signedAt: Type.Integer({ minimum: 0 }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
auth: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
|
||||
@@ -53,7 +53,16 @@ import {
|
||||
ExecApprovalsNodeSetParamsSchema,
|
||||
ExecApprovalsSetParamsSchema,
|
||||
ExecApprovalsSnapshotSchema,
|
||||
ExecApprovalRequestParamsSchema,
|
||||
ExecApprovalResolveParamsSchema,
|
||||
} from "./exec-approvals.js";
|
||||
import {
|
||||
DevicePairApproveParamsSchema,
|
||||
DevicePairListParamsSchema,
|
||||
DevicePairRejectParamsSchema,
|
||||
DevicePairRequestedEventSchema,
|
||||
DevicePairResolvedEventSchema,
|
||||
} from "./devices.js";
|
||||
import {
|
||||
ConnectParamsSchema,
|
||||
ErrorShapeSchema,
|
||||
@@ -182,6 +191,13 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
||||
ExecApprovalsNodeGetParams: ExecApprovalsNodeGetParamsSchema,
|
||||
ExecApprovalsNodeSetParams: ExecApprovalsNodeSetParamsSchema,
|
||||
ExecApprovalsSnapshot: ExecApprovalsSnapshotSchema,
|
||||
ExecApprovalRequestParams: ExecApprovalRequestParamsSchema,
|
||||
ExecApprovalResolveParams: ExecApprovalResolveParamsSchema,
|
||||
DevicePairListParams: DevicePairListParamsSchema,
|
||||
DevicePairApproveParams: DevicePairApproveParamsSchema,
|
||||
DevicePairRejectParams: DevicePairRejectParamsSchema,
|
||||
DevicePairRequestedEvent: DevicePairRequestedEventSchema,
|
||||
DevicePairResolvedEvent: DevicePairResolvedEventSchema,
|
||||
ChatHistoryParams: ChatHistoryParamsSchema,
|
||||
ChatSendParams: ChatSendParamsSchema,
|
||||
ChatAbortParams: ChatAbortParamsSchema,
|
||||
|
||||
@@ -51,7 +51,14 @@ import type {
|
||||
ExecApprovalsNodeSetParamsSchema,
|
||||
ExecApprovalsSetParamsSchema,
|
||||
ExecApprovalsSnapshotSchema,
|
||||
ExecApprovalRequestParamsSchema,
|
||||
ExecApprovalResolveParamsSchema,
|
||||
} from "./exec-approvals.js";
|
||||
import type {
|
||||
DevicePairApproveParamsSchema,
|
||||
DevicePairListParamsSchema,
|
||||
DevicePairRejectParamsSchema,
|
||||
} from "./devices.js";
|
||||
import type {
|
||||
ConnectParamsSchema,
|
||||
ErrorShapeSchema,
|
||||
@@ -175,6 +182,11 @@ export type ExecApprovalsSetParams = Static<typeof ExecApprovalsSetParamsSchema>
|
||||
export type ExecApprovalsNodeGetParams = Static<typeof ExecApprovalsNodeGetParamsSchema>;
|
||||
export type ExecApprovalsNodeSetParams = Static<typeof ExecApprovalsNodeSetParamsSchema>;
|
||||
export type ExecApprovalsSnapshot = Static<typeof ExecApprovalsSnapshotSchema>;
|
||||
export type ExecApprovalRequestParams = Static<typeof ExecApprovalRequestParamsSchema>;
|
||||
export type ExecApprovalResolveParams = Static<typeof ExecApprovalResolveParamsSchema>;
|
||||
export type DevicePairListParams = Static<typeof DevicePairListParamsSchema>;
|
||||
export type DevicePairApproveParams = Static<typeof DevicePairApproveParamsSchema>;
|
||||
export type DevicePairRejectParams = Static<typeof DevicePairRejectParamsSchema>;
|
||||
export type ChatAbortParams = Static<typeof ChatAbortParamsSchema>;
|
||||
export type ChatInjectParams = Static<typeof ChatInjectParamsSchema>;
|
||||
export type ChatEvent = Static<typeof ChatEventSchema>;
|
||||
|
||||
@@ -17,6 +17,8 @@ const BASE_METHODS = [
|
||||
"exec.approvals.set",
|
||||
"exec.approvals.node.get",
|
||||
"exec.approvals.node.set",
|
||||
"exec.approval.request",
|
||||
"exec.approval.resolve",
|
||||
"wizard.start",
|
||||
"wizard.next",
|
||||
"wizard.cancel",
|
||||
@@ -43,6 +45,9 @@ const BASE_METHODS = [
|
||||
"node.pair.approve",
|
||||
"node.pair.reject",
|
||||
"node.pair.verify",
|
||||
"device.pair.list",
|
||||
"device.pair.approve",
|
||||
"device.pair.reject",
|
||||
"node.rename",
|
||||
"node.list",
|
||||
"node.describe",
|
||||
@@ -82,5 +87,9 @@ export const GATEWAY_EVENTS = [
|
||||
"cron",
|
||||
"node.pair.requested",
|
||||
"node.pair.resolved",
|
||||
"device.pair.requested",
|
||||
"device.pair.resolved",
|
||||
"voicewake.changed",
|
||||
"exec.approval.requested",
|
||||
"exec.approval.resolved",
|
||||
];
|
||||
|
||||
@@ -6,6 +6,7 @@ import { chatHandlers } from "./server-methods/chat.js";
|
||||
import { configHandlers } from "./server-methods/config.js";
|
||||
import { connectHandlers } from "./server-methods/connect.js";
|
||||
import { cronHandlers } from "./server-methods/cron.js";
|
||||
import { deviceHandlers } from "./server-methods/devices.js";
|
||||
import { execApprovalsHandlers } from "./server-methods/exec-approvals.js";
|
||||
import { healthHandlers } from "./server-methods/health.js";
|
||||
import { logsHandlers } from "./server-methods/logs.js";
|
||||
@@ -23,6 +24,43 @@ import { voicewakeHandlers } from "./server-methods/voicewake.js";
|
||||
import { webHandlers } from "./server-methods/web.js";
|
||||
import { wizardHandlers } from "./server-methods/wizard.js";
|
||||
|
||||
const ADMIN_SCOPE = "operator.admin";
|
||||
const APPROVALS_SCOPE = "operator.approvals";
|
||||
const PAIRING_SCOPE = "operator.pairing";
|
||||
|
||||
const APPROVAL_METHODS = new Set(["exec.approval.request", "exec.approval.resolve"]);
|
||||
const PAIRING_METHODS = new Set([
|
||||
"node.pair.request",
|
||||
"node.pair.list",
|
||||
"node.pair.approve",
|
||||
"node.pair.reject",
|
||||
"node.pair.verify",
|
||||
"device.pair.list",
|
||||
"device.pair.approve",
|
||||
"device.pair.reject",
|
||||
]);
|
||||
const ADMIN_METHOD_PREFIXES = ["exec.approvals."];
|
||||
|
||||
function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["client"]) {
|
||||
if (!client?.connect) return null;
|
||||
const role = client.connect.role ?? "operator";
|
||||
const scopes = client.connect.scopes ?? [];
|
||||
if (role !== "operator") {
|
||||
return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`);
|
||||
}
|
||||
if (scopes.includes(ADMIN_SCOPE)) return null;
|
||||
if (APPROVAL_METHODS.has(method) && !scopes.includes(APPROVALS_SCOPE)) {
|
||||
return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.approvals");
|
||||
}
|
||||
if (PAIRING_METHODS.has(method) && !scopes.includes(PAIRING_SCOPE)) {
|
||||
return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.pairing");
|
||||
}
|
||||
if (ADMIN_METHOD_PREFIXES.some((prefix) => method.startsWith(prefix))) {
|
||||
return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.admin");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const coreGatewayHandlers: GatewayRequestHandlers = {
|
||||
...connectHandlers,
|
||||
...logsHandlers,
|
||||
@@ -31,6 +69,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
|
||||
...channelsHandlers,
|
||||
...chatHandlers,
|
||||
...cronHandlers,
|
||||
...deviceHandlers,
|
||||
...execApprovalsHandlers,
|
||||
...webHandlers,
|
||||
...modelsHandlers,
|
||||
@@ -52,6 +91,11 @@ export async function handleGatewayRequest(
|
||||
opts: GatewayRequestOptions & { extraHandlers?: GatewayRequestHandlers },
|
||||
): Promise<void> {
|
||||
const { req, respond, client, isWebchatConnect, context } = opts;
|
||||
const authError = authorizeGatewayMethod(req.method, client);
|
||||
if (authError) {
|
||||
respond(false, undefined, authError);
|
||||
return;
|
||||
}
|
||||
const handler = opts.extraHandlers?.[req.method] ?? coreGatewayHandlers[req.method];
|
||||
if (!handler) {
|
||||
respond(
|
||||
|
||||
98
src/gateway/server-methods/devices.ts
Normal file
98
src/gateway/server-methods/devices.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
approveDevicePairing,
|
||||
listDevicePairing,
|
||||
rejectDevicePairing,
|
||||
} from "../../infra/device-pairing.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
validateDevicePairApproveParams,
|
||||
validateDevicePairListParams,
|
||||
validateDevicePairRejectParams,
|
||||
} from "../protocol/index.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
export const deviceHandlers: GatewayRequestHandlers = {
|
||||
"device.pair.list": async ({ params, respond }) => {
|
||||
if (!validateDevicePairListParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid device.pair.list params: ${formatValidationErrors(
|
||||
validateDevicePairListParams.errors,
|
||||
)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const list = await listDevicePairing();
|
||||
respond(true, list, undefined);
|
||||
},
|
||||
"device.pair.approve": async ({ params, respond, context }) => {
|
||||
if (!validateDevicePairApproveParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid device.pair.approve params: ${formatValidationErrors(
|
||||
validateDevicePairApproveParams.errors,
|
||||
)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { requestId } = params as { requestId: string };
|
||||
const approved = await approveDevicePairing(requestId);
|
||||
if (!approved) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));
|
||||
return;
|
||||
}
|
||||
context.broadcast(
|
||||
"device.pair.resolved",
|
||||
{
|
||||
requestId,
|
||||
deviceId: approved.device.deviceId,
|
||||
decision: "approved",
|
||||
ts: Date.now(),
|
||||
},
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
respond(true, approved, undefined);
|
||||
},
|
||||
"device.pair.reject": async ({ params, respond, context }) => {
|
||||
if (!validateDevicePairRejectParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid device.pair.reject params: ${formatValidationErrors(
|
||||
validateDevicePairRejectParams.errors,
|
||||
)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { requestId } = params as { requestId: string };
|
||||
const rejected = await rejectDevicePairing(requestId);
|
||||
if (!rejected) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));
|
||||
return;
|
||||
}
|
||||
context.broadcast(
|
||||
"device.pair.resolved",
|
||||
{
|
||||
requestId,
|
||||
deviceId: rejected.deviceId,
|
||||
decision: "rejected",
|
||||
ts: Date.now(),
|
||||
},
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
respond(true, rejected, undefined);
|
||||
},
|
||||
};
|
||||
105
src/gateway/server-methods/exec-approval.ts
Normal file
105
src/gateway/server-methods/exec-approval.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { ExecApprovalDecision } from "../../infra/exec-approvals.js";
|
||||
import type { ExecApprovalManager } from "../exec-approval-manager.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
validateExecApprovalRequestParams,
|
||||
validateExecApprovalResolveParams,
|
||||
} from "../protocol/index.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
export function createExecApprovalHandlers(
|
||||
manager: ExecApprovalManager,
|
||||
): GatewayRequestHandlers {
|
||||
return {
|
||||
"exec.approval.request": async ({ params, respond, context }) => {
|
||||
if (!validateExecApprovalRequestParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid exec.approval.request params: ${formatValidationErrors(
|
||||
validateExecApprovalRequestParams.errors,
|
||||
)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const p = params as {
|
||||
command: string;
|
||||
cwd?: string;
|
||||
host?: string;
|
||||
security?: string;
|
||||
ask?: string;
|
||||
agentId?: string;
|
||||
resolvedPath?: string;
|
||||
sessionKey?: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
const timeoutMs = typeof p.timeoutMs === "number" ? p.timeoutMs : 120_000;
|
||||
const request = {
|
||||
command: p.command,
|
||||
cwd: p.cwd ?? null,
|
||||
host: p.host ?? null,
|
||||
security: p.security ?? null,
|
||||
ask: p.ask ?? null,
|
||||
agentId: p.agentId ?? null,
|
||||
resolvedPath: p.resolvedPath ?? null,
|
||||
sessionKey: p.sessionKey ?? null,
|
||||
};
|
||||
const record = manager.create(request, timeoutMs);
|
||||
context.broadcast(
|
||||
"exec.approval.requested",
|
||||
{
|
||||
id: record.id,
|
||||
request: record.request,
|
||||
createdAtMs: record.createdAtMs,
|
||||
expiresAtMs: record.expiresAtMs,
|
||||
},
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
const decision = await manager.waitForDecision(record, timeoutMs);
|
||||
respond(true, {
|
||||
id: record.id,
|
||||
decision,
|
||||
createdAtMs: record.createdAtMs,
|
||||
expiresAtMs: record.expiresAtMs,
|
||||
}, undefined);
|
||||
},
|
||||
"exec.approval.resolve": async ({ params, respond, client, context }) => {
|
||||
if (!validateExecApprovalResolveParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid exec.approval.resolve params: ${formatValidationErrors(
|
||||
validateExecApprovalResolveParams.errors,
|
||||
)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const p = params as { id: string; decision: string };
|
||||
const decision = p.decision as ExecApprovalDecision;
|
||||
if (decision !== "allow-once" && decision !== "allow-always" && decision !== "deny") {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid decision"));
|
||||
return;
|
||||
}
|
||||
const resolvedBy = client?.connect?.client?.displayName ?? client?.connect?.client?.id;
|
||||
const ok = manager.resolve(p.id, decision, resolvedBy ?? null);
|
||||
if (!ok) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown approval id"));
|
||||
return;
|
||||
}
|
||||
context.broadcast(
|
||||
"exec.approval.resolved",
|
||||
{ id: p.id, decision, resolvedBy, ts: Date.now() },
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
respond(true, { ok: true }, undefined);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -37,6 +37,8 @@ import {
|
||||
refreshGatewayHealthSnapshot,
|
||||
} from "./server/health-state.js";
|
||||
import { startGatewayBridgeRuntime } from "./server-bridge-runtime.js";
|
||||
import { ExecApprovalManager } from "./exec-approval-manager.js";
|
||||
import { createExecApprovalHandlers } from "./server-methods/exec-approval.js";
|
||||
import type { startBrowserControlServerIfEnabled } from "./server-browser.js";
|
||||
import { createChannelManager } from "./server-channels.js";
|
||||
import { createAgentEventHandler } from "./server-chat.js";
|
||||
@@ -351,6 +353,9 @@ export async function startGatewayServer(
|
||||
|
||||
void cron.start().catch((err) => logCron.error(`failed to start: ${String(err)}`));
|
||||
|
||||
const execApprovalManager = new ExecApprovalManager();
|
||||
const execApprovalHandlers = createExecApprovalHandlers(execApprovalManager);
|
||||
|
||||
attachGatewayWsHandlers({
|
||||
wss,
|
||||
clients,
|
||||
@@ -364,7 +369,10 @@ export async function startGatewayServer(
|
||||
logGateway: log,
|
||||
logHealth,
|
||||
logWsControl,
|
||||
extraHandlers: pluginRegistry.gatewayHandlers,
|
||||
extraHandlers: {
|
||||
...pluginRegistry.gatewayHandlers,
|
||||
...execApprovalHandlers,
|
||||
},
|
||||
broadcast,
|
||||
context: {
|
||||
deps,
|
||||
|
||||
@@ -2,12 +2,24 @@ import type { IncomingMessage } from "node:http";
|
||||
import os from "node:os";
|
||||
|
||||
import type { WebSocket } from "ws";
|
||||
import {
|
||||
deriveDeviceIdFromPublicKey,
|
||||
normalizeDevicePublicKeyBase64Url,
|
||||
verifyDeviceSignature,
|
||||
} from "../../../infra/device-identity.js";
|
||||
import {
|
||||
approveDevicePairing,
|
||||
getPairedDevice,
|
||||
requestDevicePairing,
|
||||
updatePairedDeviceMetadata,
|
||||
} from "../../../infra/device-pairing.js";
|
||||
import { upsertPresence } from "../../../infra/system-presence.js";
|
||||
import { rawDataToString } from "../../../infra/ws.js";
|
||||
import type { createSubsystemLogger } from "../../../logging/subsystem.js";
|
||||
import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-channel.js";
|
||||
import type { ResolvedGatewayAuth } from "../../auth.js";
|
||||
import { authorizeGatewayConnect } from "../../auth.js";
|
||||
import { buildDeviceAuthPayload } from "../../device-auth.js";
|
||||
import { isLoopbackAddress } from "../../net.js";
|
||||
import {
|
||||
type ConnectParams,
|
||||
@@ -38,6 +50,8 @@ import type { GatewayWsClient } from "../ws-types.js";
|
||||
|
||||
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
|
||||
|
||||
const DEVICE_SIGNATURE_SKEW_MS = 10 * 60 * 1000;
|
||||
|
||||
export function attachGatewayWsMessageHandler(params: {
|
||||
socket: WebSocket;
|
||||
upgradeReq: IncomingMessage;
|
||||
@@ -236,6 +250,163 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
}
|
||||
const authMethod = authResult.method ?? "none";
|
||||
|
||||
const role = connectParams.role ?? "operator";
|
||||
const scopes = Array.isArray(connectParams.scopes)
|
||||
? connectParams.scopes
|
||||
: role === "operator"
|
||||
? ["operator.admin"]
|
||||
: [];
|
||||
connectParams.role = role;
|
||||
connectParams.scopes = scopes;
|
||||
|
||||
const device = connectParams.device;
|
||||
let devicePublicKey: string | null = null;
|
||||
if (device) {
|
||||
const derivedId = deriveDeviceIdFromPublicKey(device.publicKey);
|
||||
if (!derivedId || derivedId !== device.id) {
|
||||
setHandshakeState("failed");
|
||||
setCloseCause("device-auth-invalid", {
|
||||
reason: "device-id-mismatch",
|
||||
client: connectParams.client.id,
|
||||
deviceId: device.id,
|
||||
});
|
||||
send({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.INVALID_REQUEST, "device identity mismatch"),
|
||||
});
|
||||
close(1008, "device identity mismatch");
|
||||
return;
|
||||
}
|
||||
const signedAt = device.signedAt;
|
||||
if (
|
||||
typeof signedAt !== "number" ||
|
||||
Math.abs(Date.now() - signedAt) > DEVICE_SIGNATURE_SKEW_MS
|
||||
) {
|
||||
setHandshakeState("failed");
|
||||
setCloseCause("device-auth-invalid", {
|
||||
reason: "device-signature-stale",
|
||||
client: connectParams.client.id,
|
||||
deviceId: device.id,
|
||||
});
|
||||
send({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.INVALID_REQUEST, "device signature expired"),
|
||||
});
|
||||
close(1008, "device signature expired");
|
||||
return;
|
||||
}
|
||||
const payload = buildDeviceAuthPayload({
|
||||
deviceId: device.id,
|
||||
clientId: connectParams.client.id,
|
||||
clientMode: connectParams.client.mode,
|
||||
role,
|
||||
scopes,
|
||||
signedAtMs: signedAt,
|
||||
token: connectParams.auth?.token ?? null,
|
||||
});
|
||||
if (!verifyDeviceSignature(device.publicKey, payload, device.signature)) {
|
||||
setHandshakeState("failed");
|
||||
setCloseCause("device-auth-invalid", {
|
||||
reason: "device-signature",
|
||||
client: connectParams.client.id,
|
||||
deviceId: device.id,
|
||||
});
|
||||
send({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.INVALID_REQUEST, "device signature invalid"),
|
||||
});
|
||||
close(1008, "device signature invalid");
|
||||
return;
|
||||
}
|
||||
devicePublicKey = normalizeDevicePublicKeyBase64Url(device.publicKey);
|
||||
if (!devicePublicKey) {
|
||||
setHandshakeState("failed");
|
||||
setCloseCause("device-auth-invalid", {
|
||||
reason: "device-public-key",
|
||||
client: connectParams.client.id,
|
||||
deviceId: device.id,
|
||||
});
|
||||
send({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.INVALID_REQUEST, "device public key invalid"),
|
||||
});
|
||||
close(1008, "device public key invalid");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (device && devicePublicKey) {
|
||||
const paired = await getPairedDevice(device.id);
|
||||
const isPaired = paired?.publicKey === devicePublicKey;
|
||||
if (!isPaired) {
|
||||
const pairing = await requestDevicePairing({
|
||||
deviceId: device.id,
|
||||
publicKey: devicePublicKey,
|
||||
displayName: connectParams.client.displayName,
|
||||
platform: connectParams.client.platform,
|
||||
clientId: connectParams.client.id,
|
||||
clientMode: connectParams.client.mode,
|
||||
role,
|
||||
scopes,
|
||||
remoteIp: remoteAddr,
|
||||
silent: isLoopbackAddress(remoteAddr) && authMethod !== "none",
|
||||
});
|
||||
const context = buildRequestContext();
|
||||
if (pairing.request.silent === true) {
|
||||
const approved = await approveDevicePairing(pairing.request.requestId);
|
||||
if (approved) {
|
||||
context.broadcast(
|
||||
"device.pair.resolved",
|
||||
{
|
||||
requestId: pairing.request.requestId,
|
||||
deviceId: approved.device.deviceId,
|
||||
decision: "approved",
|
||||
ts: Date.now(),
|
||||
},
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
}
|
||||
} else if (pairing.created) {
|
||||
context.broadcast("device.pair.requested", pairing.request, { dropIfSlow: true });
|
||||
}
|
||||
if (pairing.request.silent !== true) {
|
||||
setHandshakeState("failed");
|
||||
setCloseCause("pairing-required", {
|
||||
deviceId: device.id,
|
||||
requestId: pairing.request.requestId,
|
||||
});
|
||||
send({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.NOT_PAIRED, "pairing required", {
|
||||
details: { requestId: pairing.request.requestId },
|
||||
}),
|
||||
});
|
||||
close(1008, "pairing required");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await updatePairedDeviceMetadata(device.id, {
|
||||
displayName: connectParams.client.displayName,
|
||||
platform: connectParams.client.platform,
|
||||
clientId: connectParams.client.id,
|
||||
clientMode: connectParams.client.mode,
|
||||
role,
|
||||
scopes,
|
||||
remoteIp: remoteAddr,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const shouldTrackPresence = !isGatewayCliClient(connectParams.client);
|
||||
const clientId = connectParams.client.id;
|
||||
const instanceId = connectParams.client.instanceId;
|
||||
|
||||
Reference in New Issue
Block a user