feat(discovery): gateway bonjour + node pairing bridge
This commit is contained in:
112
src/infra/bonjour.ts
Normal file
112
src/infra/bonjour.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import os from "node:os";
|
||||
|
||||
import { type CiaoService, getResponder, Protocol } from "@homebridge/ciao";
|
||||
|
||||
export type GatewayBonjourAdvertiser = {
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type GatewayBonjourAdvertiseOpts = {
|
||||
instanceName?: string;
|
||||
gatewayPort: number;
|
||||
sshPort?: number;
|
||||
bridgePort?: number;
|
||||
tailnetDns?: string;
|
||||
};
|
||||
|
||||
function isDisabledByEnv() {
|
||||
if (process.env.CLAWDIS_DISABLE_BONJOUR === "1") return true;
|
||||
if (process.env.NODE_ENV === "test") return true;
|
||||
if (process.env.VITEST) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function safeServiceName(name: string) {
|
||||
const trimmed = name.trim();
|
||||
return trimmed.length > 0 ? trimmed : "Clawdis";
|
||||
}
|
||||
|
||||
export async function startGatewayBonjourAdvertiser(
|
||||
opts: GatewayBonjourAdvertiseOpts,
|
||||
): Promise<GatewayBonjourAdvertiser> {
|
||||
if (isDisabledByEnv()) {
|
||||
return { stop: async () => {} };
|
||||
}
|
||||
|
||||
const responder = getResponder();
|
||||
|
||||
const hostname = os.hostname().replace(/\.local$/i, "");
|
||||
const instanceName =
|
||||
typeof opts.instanceName === "string" && opts.instanceName.trim()
|
||||
? opts.instanceName.trim()
|
||||
: `${hostname} (Clawdis)`;
|
||||
|
||||
const txtBase: Record<string, string> = {
|
||||
role: "master",
|
||||
gatewayPort: String(opts.gatewayPort),
|
||||
lanHost: `${hostname}.local`,
|
||||
};
|
||||
if (typeof opts.bridgePort === "number" && opts.bridgePort > 0) {
|
||||
txtBase.bridgePort = String(opts.bridgePort);
|
||||
}
|
||||
if (typeof opts.tailnetDns === "string" && opts.tailnetDns.trim()) {
|
||||
txtBase.tailnetDns = opts.tailnetDns.trim();
|
||||
}
|
||||
|
||||
const services: CiaoService[] = [];
|
||||
|
||||
// Master beacon: used for discovery (auto-fill SSH/direct targets).
|
||||
// We advertise a TCP service so clients can resolve the host; the port itself is informational.
|
||||
const master = responder.createService({
|
||||
name: safeServiceName(instanceName),
|
||||
type: "clawdis-master",
|
||||
protocol: Protocol.TCP,
|
||||
port: opts.sshPort ?? 22,
|
||||
txt: {
|
||||
...txtBase,
|
||||
sshPort: String(opts.sshPort ?? 22),
|
||||
},
|
||||
});
|
||||
services.push(master);
|
||||
|
||||
// Optional bridge beacon (same type used by Iris/iOS today).
|
||||
if (typeof opts.bridgePort === "number" && opts.bridgePort > 0) {
|
||||
const bridge = responder.createService({
|
||||
name: safeServiceName(instanceName),
|
||||
type: "clawdis-bridge",
|
||||
protocol: Protocol.TCP,
|
||||
port: opts.bridgePort,
|
||||
txt: {
|
||||
...txtBase,
|
||||
transport: "bridge",
|
||||
},
|
||||
});
|
||||
services.push(bridge);
|
||||
}
|
||||
|
||||
// Do not block gateway startup on mDNS probing/announce. Advertising can take
|
||||
// multiple seconds depending on network state; the gateway should come up even
|
||||
// if Bonjour is slow or fails.
|
||||
for (const svc of services) {
|
||||
void svc.advertise().catch(() => {
|
||||
/* ignore */
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
stop: async () => {
|
||||
for (const svc of services) {
|
||||
try {
|
||||
await svc.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
try {
|
||||
await responder.shutdown();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
129
src/infra/bridge/server.test.ts
Normal file
129
src/infra/bridge/server.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import fs from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { approveNodePairing, listNodePairing } from "../node-pairing.js";
|
||||
import { startNodeBridgeServer } from "./server.js";
|
||||
|
||||
function createLineReader(socket: net.Socket) {
|
||||
let buffer = "";
|
||||
const pending: Array<(line: string) => void> = [];
|
||||
|
||||
const flush = () => {
|
||||
while (pending.length > 0) {
|
||||
const idx = buffer.indexOf("\n");
|
||||
if (idx === -1) return;
|
||||
const line = buffer.slice(0, idx);
|
||||
buffer = buffer.slice(idx + 1);
|
||||
const resolve = pending.shift();
|
||||
resolve?.(line);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk.toString("utf8");
|
||||
flush();
|
||||
});
|
||||
|
||||
const readLine = async () => {
|
||||
flush();
|
||||
const idx = buffer.indexOf("\n");
|
||||
if (idx !== -1) {
|
||||
const line = buffer.slice(0, idx);
|
||||
buffer = buffer.slice(idx + 1);
|
||||
return line;
|
||||
}
|
||||
return await new Promise<string>((resolve) => pending.push(resolve));
|
||||
};
|
||||
|
||||
return readLine;
|
||||
}
|
||||
|
||||
function sendLine(socket: net.Socket, obj: unknown) {
|
||||
socket.write(`${JSON.stringify(obj)}\n`);
|
||||
}
|
||||
|
||||
describe("node bridge server", () => {
|
||||
let baseDir = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.CLAWDIS_ENABLE_BRIDGE_IN_TESTS = "1";
|
||||
baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-bridge-test-"));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(baseDir, { recursive: true, force: true });
|
||||
delete process.env.CLAWDIS_ENABLE_BRIDGE_IN_TESTS;
|
||||
});
|
||||
|
||||
it("rejects hello when not paired", async () => {
|
||||
const server = await startNodeBridgeServer({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
pairingBaseDir: baseDir,
|
||||
});
|
||||
|
||||
const socket = net.connect({ host: "127.0.0.1", port: server.port });
|
||||
const readLine = createLineReader(socket);
|
||||
sendLine(socket, { type: "hello", nodeId: "n1" });
|
||||
const line = await readLine();
|
||||
const msg = JSON.parse(line) as { type: string; code?: string };
|
||||
expect(msg.type).toBe("error");
|
||||
expect(msg.code).toBe("NOT_PAIRED");
|
||||
socket.destroy();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("pairs after approval and then accepts hello", async () => {
|
||||
const server = await startNodeBridgeServer({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
pairingBaseDir: baseDir,
|
||||
});
|
||||
|
||||
const socket = net.connect({ host: "127.0.0.1", port: server.port });
|
||||
const readLine = createLineReader(socket);
|
||||
sendLine(socket, { type: "pair-request", nodeId: "n2", platform: "ios" });
|
||||
|
||||
// Approve the pending request from the gateway side.
|
||||
let reqId: string | undefined;
|
||||
for (let i = 0; i < 40; i += 1) {
|
||||
const list = await listNodePairing(baseDir);
|
||||
const req = list.pending.find((p) => p.nodeId === "n2");
|
||||
if (req) {
|
||||
reqId = req.requestId;
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
expect(reqId).toBeTruthy();
|
||||
if (!reqId) throw new Error("expected a pending requestId");
|
||||
await approveNodePairing(reqId, baseDir);
|
||||
|
||||
const line1 = JSON.parse(await readLine()) as {
|
||||
type: string;
|
||||
token?: string;
|
||||
};
|
||||
expect(line1.type).toBe("pair-ok");
|
||||
expect(typeof line1.token).toBe("string");
|
||||
if (!line1.token) throw new Error("expected pair-ok token");
|
||||
const token = line1.token;
|
||||
|
||||
const line2 = JSON.parse(await readLine()) as { type: string };
|
||||
expect(line2.type).toBe("hello-ok");
|
||||
|
||||
socket.destroy();
|
||||
|
||||
const socket2 = net.connect({ host: "127.0.0.1", port: server.port });
|
||||
const readLine2 = createLineReader(socket2);
|
||||
sendLine(socket2, { type: "hello", nodeId: "n2", token });
|
||||
const line3 = JSON.parse(await readLine2()) as { type: string };
|
||||
expect(line3.type).toBe("hello-ok");
|
||||
socket2.destroy();
|
||||
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
356
src/infra/bridge/server.ts
Normal file
356
src/infra/bridge/server.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
|
||||
import {
|
||||
getPairedNode,
|
||||
listNodePairing,
|
||||
requestNodePairing,
|
||||
verifyNodeToken,
|
||||
} from "../node-pairing.js";
|
||||
|
||||
type BridgeHelloFrame = {
|
||||
type: "hello";
|
||||
nodeId: string;
|
||||
displayName?: string;
|
||||
token?: string;
|
||||
platform?: string;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
type BridgePairRequestFrame = {
|
||||
type: "pair-request";
|
||||
nodeId: string;
|
||||
displayName?: string;
|
||||
platform?: string;
|
||||
version?: string;
|
||||
remoteAddress?: string;
|
||||
};
|
||||
|
||||
type BridgeEventFrame = {
|
||||
type: "event";
|
||||
event: string;
|
||||
payloadJSON?: string | null;
|
||||
};
|
||||
|
||||
type BridgePingFrame = { type: "ping"; id: string };
|
||||
type BridgePongFrame = { type: "pong"; id: string };
|
||||
|
||||
type BridgeInvokeResponseFrame = {
|
||||
type: "invoke-res";
|
||||
id: string;
|
||||
ok: boolean;
|
||||
payloadJSON?: string | null;
|
||||
error?: { code: string; message: string } | null;
|
||||
};
|
||||
|
||||
type BridgeHelloOkFrame = { type: "hello-ok"; serverName: string };
|
||||
type BridgePairOkFrame = { type: "pair-ok"; token: string };
|
||||
type BridgeErrorFrame = { type: "error"; code: string; message: string };
|
||||
|
||||
type AnyBridgeFrame =
|
||||
| BridgeHelloFrame
|
||||
| BridgePairRequestFrame
|
||||
| BridgeEventFrame
|
||||
| BridgePingFrame
|
||||
| BridgePongFrame
|
||||
| BridgeInvokeResponseFrame
|
||||
| BridgeHelloOkFrame
|
||||
| BridgePairOkFrame
|
||||
| BridgeErrorFrame
|
||||
| { type: string; [k: string]: unknown };
|
||||
|
||||
export type NodeBridgeServer = {
|
||||
port: number;
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type NodeBridgeServerOpts = {
|
||||
host: string;
|
||||
port: number; // 0 = ephemeral
|
||||
pairingBaseDir?: string;
|
||||
onEvent?: (nodeId: string, evt: BridgeEventFrame) => Promise<void> | void;
|
||||
onAuthenticated?: (nodeId: string) => Promise<void> | void;
|
||||
onDisconnected?: (nodeId: string) => Promise<void> | void;
|
||||
serverName?: string;
|
||||
};
|
||||
|
||||
function isTestEnv() {
|
||||
return process.env.NODE_ENV === "test" || Boolean(process.env.VITEST);
|
||||
}
|
||||
|
||||
function encodeLine(frame: AnyBridgeFrame) {
|
||||
return `${JSON.stringify(frame)}\n`;
|
||||
}
|
||||
|
||||
async function sleep(ms: number) {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export async function startNodeBridgeServer(
|
||||
opts: NodeBridgeServerOpts,
|
||||
): Promise<NodeBridgeServer> {
|
||||
if (isTestEnv() && process.env.CLAWDIS_ENABLE_BRIDGE_IN_TESTS !== "1") {
|
||||
return {
|
||||
port: 0,
|
||||
close: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
const serverName =
|
||||
typeof opts.serverName === "string" && opts.serverName.trim()
|
||||
? opts.serverName.trim()
|
||||
: os.hostname();
|
||||
|
||||
const connections = new Map<string, net.Socket>();
|
||||
|
||||
const server = net.createServer((socket) => {
|
||||
socket.setNoDelay(true);
|
||||
|
||||
let buffer = "";
|
||||
let isAuthenticated = false;
|
||||
let nodeId: string | null = null;
|
||||
const invokeWaiters = new Map<
|
||||
string,
|
||||
{
|
||||
resolve: (value: BridgeInvokeResponseFrame) => void;
|
||||
reject: (err: Error) => void;
|
||||
}
|
||||
>();
|
||||
|
||||
const abort = new AbortController();
|
||||
const stop = () => {
|
||||
if (!abort.signal.aborted) abort.abort();
|
||||
if (nodeId) connections.delete(nodeId);
|
||||
for (const [, waiter] of invokeWaiters) {
|
||||
waiter.reject(new Error("bridge connection closed"));
|
||||
}
|
||||
invokeWaiters.clear();
|
||||
};
|
||||
|
||||
const send = (frame: AnyBridgeFrame) => {
|
||||
try {
|
||||
socket.write(encodeLine(frame));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const sendError = (code: string, message: string) => {
|
||||
send({ type: "error", code, message } satisfies BridgeErrorFrame);
|
||||
};
|
||||
|
||||
const remoteAddress = (() => {
|
||||
const addr = socket.remoteAddress?.trim();
|
||||
return addr && addr.length > 0 ? addr : undefined;
|
||||
})();
|
||||
|
||||
const handleHello = async (hello: BridgeHelloFrame) => {
|
||||
nodeId = String(hello.nodeId ?? "").trim();
|
||||
if (!nodeId) {
|
||||
sendError("INVALID_REQUEST", "nodeId required");
|
||||
return;
|
||||
}
|
||||
|
||||
const token = typeof hello.token === "string" ? hello.token.trim() : "";
|
||||
if (!token) {
|
||||
const paired = await getPairedNode(nodeId, opts.pairingBaseDir);
|
||||
sendError(paired ? "UNAUTHORIZED" : "NOT_PAIRED", "pairing required");
|
||||
return;
|
||||
}
|
||||
|
||||
const verified = await verifyNodeToken(
|
||||
nodeId,
|
||||
token,
|
||||
opts.pairingBaseDir,
|
||||
);
|
||||
if (!verified.ok) {
|
||||
sendError("UNAUTHORIZED", "invalid token");
|
||||
return;
|
||||
}
|
||||
|
||||
isAuthenticated = true;
|
||||
connections.set(nodeId, socket);
|
||||
send({ type: "hello-ok", serverName } satisfies BridgeHelloOkFrame);
|
||||
await opts.onAuthenticated?.(nodeId);
|
||||
};
|
||||
|
||||
const waitForApproval = async (request: {
|
||||
requestId: string;
|
||||
nodeId: string;
|
||||
ts: number;
|
||||
isRepair?: boolean;
|
||||
}): Promise<
|
||||
{ ok: true; token: string } | { ok: false; reason: string }
|
||||
> => {
|
||||
const deadline = Date.now() + 5 * 60 * 1000;
|
||||
while (!abort.signal.aborted && Date.now() < deadline) {
|
||||
const list = await listNodePairing(opts.pairingBaseDir);
|
||||
const stillPending = list.pending.some(
|
||||
(p) => p.requestId === request.requestId,
|
||||
);
|
||||
if (stillPending) {
|
||||
await sleep(250);
|
||||
continue;
|
||||
}
|
||||
|
||||
const paired = await getPairedNode(request.nodeId, opts.pairingBaseDir);
|
||||
if (!paired) return { ok: false, reason: "pairing rejected" };
|
||||
|
||||
// For a repair, ensure this approval happened after the request was created.
|
||||
if (paired.approvedAtMs < request.ts) {
|
||||
return { ok: false, reason: "pairing rejected" };
|
||||
}
|
||||
|
||||
return { ok: true, token: paired.token };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
reason: abort.signal.aborted ? "disconnected" : "pairing expired",
|
||||
};
|
||||
};
|
||||
|
||||
const handlePairRequest = async (req: BridgePairRequestFrame) => {
|
||||
nodeId = String(req.nodeId ?? "").trim();
|
||||
if (!nodeId) {
|
||||
sendError("INVALID_REQUEST", "nodeId required");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await requestNodePairing(
|
||||
{
|
||||
nodeId,
|
||||
displayName: req.displayName,
|
||||
platform: req.platform,
|
||||
version: req.version,
|
||||
remoteIp: remoteAddress,
|
||||
},
|
||||
opts.pairingBaseDir,
|
||||
);
|
||||
|
||||
const wait = await waitForApproval(result.request);
|
||||
if (!wait.ok) {
|
||||
sendError("UNAUTHORIZED", wait.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
isAuthenticated = true;
|
||||
connections.set(nodeId, socket);
|
||||
send({ type: "pair-ok", token: wait.token } satisfies BridgePairOkFrame);
|
||||
send({ type: "hello-ok", serverName } satisfies BridgeHelloOkFrame);
|
||||
await opts.onAuthenticated?.(nodeId);
|
||||
};
|
||||
|
||||
const handleEvent = async (evt: BridgeEventFrame) => {
|
||||
if (!isAuthenticated || !nodeId) {
|
||||
sendError("UNAUTHORIZED", "not authenticated");
|
||||
return;
|
||||
}
|
||||
await opts.onEvent?.(nodeId, evt);
|
||||
};
|
||||
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk.toString("utf8");
|
||||
while (true) {
|
||||
const idx = buffer.indexOf("\n");
|
||||
if (idx === -1) break;
|
||||
const line = buffer.slice(0, idx);
|
||||
buffer = buffer.slice(idx + 1);
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
void (async () => {
|
||||
let frame: AnyBridgeFrame;
|
||||
try {
|
||||
frame = JSON.parse(trimmed) as AnyBridgeFrame;
|
||||
} catch (err) {
|
||||
sendError("INVALID_REQUEST", String(err));
|
||||
return;
|
||||
}
|
||||
|
||||
const type = typeof frame.type === "string" ? frame.type : "";
|
||||
try {
|
||||
switch (type) {
|
||||
case "hello":
|
||||
await handleHello(frame as BridgeHelloFrame);
|
||||
break;
|
||||
case "pair-request":
|
||||
await handlePairRequest(frame as BridgePairRequestFrame);
|
||||
break;
|
||||
case "event":
|
||||
await handleEvent(frame as BridgeEventFrame);
|
||||
break;
|
||||
case "ping": {
|
||||
if (!isAuthenticated) {
|
||||
sendError("UNAUTHORIZED", "not authenticated");
|
||||
break;
|
||||
}
|
||||
const ping = frame as BridgePingFrame;
|
||||
send({
|
||||
type: "pong",
|
||||
id: String(ping.id ?? ""),
|
||||
} satisfies BridgePongFrame);
|
||||
break;
|
||||
}
|
||||
case "invoke-res": {
|
||||
if (!isAuthenticated) {
|
||||
sendError("UNAUTHORIZED", "not authenticated");
|
||||
break;
|
||||
}
|
||||
const res = frame as BridgeInvokeResponseFrame;
|
||||
const waiter = invokeWaiters.get(res.id);
|
||||
if (waiter) {
|
||||
invokeWaiters.delete(res.id);
|
||||
waiter.resolve(res);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "pong":
|
||||
// ignore
|
||||
break;
|
||||
default:
|
||||
sendError("INVALID_REQUEST", "unknown type");
|
||||
}
|
||||
} catch (err) {
|
||||
sendError("INVALID_REQUEST", String(err));
|
||||
}
|
||||
})();
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("close", () => {
|
||||
const id = nodeId;
|
||||
stop();
|
||||
if (id && isAuthenticated) void opts.onDisconnected?.(id);
|
||||
});
|
||||
socket.on("error", () => {
|
||||
// close handler will run after close
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(opts.port, opts.host, () => resolve());
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
const port =
|
||||
typeof address === "object" && address ? address.port : opts.port;
|
||||
|
||||
return {
|
||||
port,
|
||||
close: async () => {
|
||||
for (const sock of connections.values()) {
|
||||
try {
|
||||
sock.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
connections.clear();
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close((err) => (err ? reject(err) : resolve())),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
238
src/infra/node-pairing.ts
Normal file
238
src/infra/node-pairing.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export type NodePairingPendingRequest = {
|
||||
requestId: string;
|
||||
nodeId: string;
|
||||
displayName?: string;
|
||||
platform?: string;
|
||||
version?: string;
|
||||
remoteIp?: string;
|
||||
isRepair?: boolean;
|
||||
ts: number;
|
||||
};
|
||||
|
||||
export type NodePairingPairedNode = {
|
||||
nodeId: string;
|
||||
token: string;
|
||||
displayName?: string;
|
||||
platform?: string;
|
||||
version?: string;
|
||||
remoteIp?: string;
|
||||
createdAtMs: number;
|
||||
approvedAtMs: number;
|
||||
};
|
||||
|
||||
export type NodePairingList = {
|
||||
pending: NodePairingPendingRequest[];
|
||||
paired: NodePairingPairedNode[];
|
||||
};
|
||||
|
||||
type NodePairingStateFile = {
|
||||
pendingById: Record<string, NodePairingPendingRequest>;
|
||||
pairedByNodeId: Record<string, NodePairingPairedNode>;
|
||||
};
|
||||
|
||||
const PENDING_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
function defaultBaseDir() {
|
||||
return path.join(os.homedir(), ".clawdis");
|
||||
}
|
||||
|
||||
function resolvePaths(baseDir?: string) {
|
||||
const root = baseDir ?? defaultBaseDir();
|
||||
const dir = path.join(root, "nodes");
|
||||
return {
|
||||
dir,
|
||||
pendingPath: path.join(dir, "pending.json"),
|
||||
pairedPath: path.join(dir, "paired.json"),
|
||||
};
|
||||
}
|
||||
|
||||
async function readJSON<T>(filePath: string): Promise<T | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, "utf8");
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeJSONAtomic(filePath: string, value: unknown) {
|
||||
const dir = path.dirname(filePath);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const tmp = `${filePath}.${randomUUID()}.tmp`;
|
||||
await fs.writeFile(tmp, JSON.stringify(value, null, 2), "utf8");
|
||||
await fs.rename(tmp, filePath);
|
||||
}
|
||||
|
||||
function pruneExpiredPending(
|
||||
pendingById: Record<string, NodePairingPendingRequest>,
|
||||
nowMs: number,
|
||||
) {
|
||||
for (const [id, req] of Object.entries(pendingById)) {
|
||||
if (nowMs - req.ts > PENDING_TTL_MS) {
|
||||
delete pendingById[id];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let lock: Promise<void> = Promise.resolve();
|
||||
async function withLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const prev = lock;
|
||||
let release: (() => void) | undefined;
|
||||
lock = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await prev;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
release?.();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadState(baseDir?: string): Promise<NodePairingStateFile> {
|
||||
const { pendingPath, pairedPath } = resolvePaths(baseDir);
|
||||
const [pending, paired] = await Promise.all([
|
||||
readJSON<Record<string, NodePairingPendingRequest>>(pendingPath),
|
||||
readJSON<Record<string, NodePairingPairedNode>>(pairedPath),
|
||||
]);
|
||||
const state: NodePairingStateFile = {
|
||||
pendingById: pending ?? {},
|
||||
pairedByNodeId: paired ?? {},
|
||||
};
|
||||
pruneExpiredPending(state.pendingById, Date.now());
|
||||
return state;
|
||||
}
|
||||
|
||||
async function persistState(state: NodePairingStateFile, baseDir?: string) {
|
||||
const { pendingPath, pairedPath } = resolvePaths(baseDir);
|
||||
await Promise.all([
|
||||
writeJSONAtomic(pendingPath, state.pendingById),
|
||||
writeJSONAtomic(pairedPath, state.pairedByNodeId),
|
||||
]);
|
||||
}
|
||||
|
||||
function normalizeNodeId(nodeId: string) {
|
||||
return nodeId.trim();
|
||||
}
|
||||
|
||||
function newToken() {
|
||||
return randomUUID().replaceAll("-", "");
|
||||
}
|
||||
|
||||
export async function listNodePairing(
|
||||
baseDir?: string,
|
||||
): Promise<NodePairingList> {
|
||||
const state = await loadState(baseDir);
|
||||
const pending = Object.values(state.pendingById).sort((a, b) => b.ts - a.ts);
|
||||
const paired = Object.values(state.pairedByNodeId).sort(
|
||||
(a, b) => b.approvedAtMs - a.approvedAtMs,
|
||||
);
|
||||
return { pending, paired };
|
||||
}
|
||||
|
||||
export async function getPairedNode(
|
||||
nodeId: string,
|
||||
baseDir?: string,
|
||||
): Promise<NodePairingPairedNode | null> {
|
||||
const state = await loadState(baseDir);
|
||||
return state.pairedByNodeId[normalizeNodeId(nodeId)] ?? null;
|
||||
}
|
||||
|
||||
export async function requestNodePairing(
|
||||
req: Omit<NodePairingPendingRequest, "requestId" | "ts" | "isRepair">,
|
||||
baseDir?: string,
|
||||
): Promise<{
|
||||
status: "pending";
|
||||
request: NodePairingPendingRequest;
|
||||
created: boolean;
|
||||
}> {
|
||||
return await withLock(async () => {
|
||||
const state = await loadState(baseDir);
|
||||
const nodeId = normalizeNodeId(req.nodeId);
|
||||
if (!nodeId) {
|
||||
throw new Error("nodeId required");
|
||||
}
|
||||
|
||||
const existing = Object.values(state.pendingById).find(
|
||||
(p) => p.nodeId === nodeId,
|
||||
);
|
||||
if (existing) {
|
||||
return { status: "pending", request: existing, created: false };
|
||||
}
|
||||
|
||||
const isRepair = Boolean(state.pairedByNodeId[nodeId]);
|
||||
const request: NodePairingPendingRequest = {
|
||||
requestId: randomUUID(),
|
||||
nodeId,
|
||||
displayName: req.displayName,
|
||||
platform: req.platform,
|
||||
version: req.version,
|
||||
remoteIp: req.remoteIp,
|
||||
isRepair,
|
||||
ts: Date.now(),
|
||||
};
|
||||
state.pendingById[request.requestId] = request;
|
||||
await persistState(state, baseDir);
|
||||
return { status: "pending", request, created: true };
|
||||
});
|
||||
}
|
||||
|
||||
export async function approveNodePairing(
|
||||
requestId: string,
|
||||
baseDir?: string,
|
||||
): Promise<{ requestId: string; node: NodePairingPairedNode } | null> {
|
||||
return await withLock(async () => {
|
||||
const state = await loadState(baseDir);
|
||||
const pending = state.pendingById[requestId];
|
||||
if (!pending) return null;
|
||||
|
||||
const now = Date.now();
|
||||
const existing = state.pairedByNodeId[pending.nodeId];
|
||||
const node: NodePairingPairedNode = {
|
||||
nodeId: pending.nodeId,
|
||||
token: newToken(),
|
||||
displayName: pending.displayName,
|
||||
platform: pending.platform,
|
||||
version: pending.version,
|
||||
remoteIp: pending.remoteIp,
|
||||
createdAtMs: existing?.createdAtMs ?? now,
|
||||
approvedAtMs: now,
|
||||
};
|
||||
|
||||
delete state.pendingById[requestId];
|
||||
state.pairedByNodeId[pending.nodeId] = node;
|
||||
await persistState(state, baseDir);
|
||||
return { requestId, node };
|
||||
});
|
||||
}
|
||||
|
||||
export async function rejectNodePairing(
|
||||
requestId: string,
|
||||
baseDir?: string,
|
||||
): Promise<{ requestId: string; nodeId: string } | null> {
|
||||
return await withLock(async () => {
|
||||
const state = await loadState(baseDir);
|
||||
const pending = state.pendingById[requestId];
|
||||
if (!pending) return null;
|
||||
delete state.pendingById[requestId];
|
||||
await persistState(state, baseDir);
|
||||
return { requestId, nodeId: pending.nodeId };
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyNodeToken(
|
||||
nodeId: string,
|
||||
token: string,
|
||||
baseDir?: string,
|
||||
): Promise<{ ok: boolean; node?: NodePairingPairedNode }> {
|
||||
const state = await loadState(baseDir);
|
||||
const normalized = normalizeNodeId(nodeId);
|
||||
const node = state.pairedByNodeId[normalized];
|
||||
if (!node) return { ok: false };
|
||||
return node.token === token ? { ok: true, node } : { ok: false };
|
||||
}
|
||||
Reference in New Issue
Block a user