172 lines
5.1 KiB
TypeScript
172 lines
5.1 KiB
TypeScript
import type { NodeBridgeServer } from "../infra/bridge/server.js";
|
|
import { startNodeBridgeServer } from "../infra/bridge/server.js";
|
|
import {
|
|
listSystemPresence,
|
|
upsertPresence,
|
|
} from "../infra/system-presence.js";
|
|
import { loadVoiceWakeConfig } from "../infra/voicewake.js";
|
|
import { isLoopbackAddress } from "./net.js";
|
|
import {
|
|
getHealthVersion,
|
|
getPresenceVersion,
|
|
incrementPresenceVersion,
|
|
} from "./server/health-state.js";
|
|
import type {
|
|
BridgeEvent,
|
|
BridgeRequest,
|
|
BridgeResponse,
|
|
} from "./server-bridge-types.js";
|
|
|
|
export type GatewayNodeBridgeRuntime = {
|
|
bridge: NodeBridgeServer | null;
|
|
nodePresenceTimers: Map<string, ReturnType<typeof setInterval>>;
|
|
};
|
|
|
|
export async function startGatewayNodeBridge(params: {
|
|
bridgeEnabled: boolean;
|
|
bridgePort: number;
|
|
bridgeHost: string | null;
|
|
machineDisplayName: string;
|
|
canvasHostPort?: number;
|
|
canvasHostHost?: string;
|
|
broadcast: (
|
|
event: string,
|
|
payload: unknown,
|
|
opts?: {
|
|
dropIfSlow?: boolean;
|
|
stateVersion?: { presence?: number; health?: number };
|
|
},
|
|
) => void;
|
|
bridgeUnsubscribeAll: (nodeId: string) => void;
|
|
handleBridgeRequest: (
|
|
nodeId: string,
|
|
req: BridgeRequest,
|
|
) => Promise<BridgeResponse>;
|
|
handleBridgeEvent: (nodeId: string, evt: BridgeEvent) => Promise<void> | void;
|
|
logBridge: { info: (msg: string) => void; warn: (msg: string) => void };
|
|
}): Promise<GatewayNodeBridgeRuntime> {
|
|
const nodePresenceTimers = new Map<string, ReturnType<typeof setInterval>>();
|
|
|
|
const stopNodePresenceTimer = (nodeId: string) => {
|
|
const timer = nodePresenceTimers.get(nodeId);
|
|
if (timer) {
|
|
clearInterval(timer);
|
|
}
|
|
nodePresenceTimers.delete(nodeId);
|
|
};
|
|
|
|
const beaconNodePresence = (
|
|
node: {
|
|
nodeId: string;
|
|
displayName?: string;
|
|
remoteIp?: string;
|
|
version?: string;
|
|
platform?: string;
|
|
deviceFamily?: string;
|
|
modelIdentifier?: string;
|
|
},
|
|
reason: string,
|
|
) => {
|
|
const host = node.displayName?.trim() || node.nodeId;
|
|
const rawIp = node.remoteIp?.trim();
|
|
const ip = rawIp && !isLoopbackAddress(rawIp) ? rawIp : undefined;
|
|
const version = node.version?.trim() || "unknown";
|
|
const platform = node.platform?.trim() || undefined;
|
|
const deviceFamily = node.deviceFamily?.trim() || undefined;
|
|
const modelIdentifier = node.modelIdentifier?.trim() || undefined;
|
|
const text = `Node: ${host}${ip ? ` (${ip})` : ""} · app ${version} · last input 0s ago · mode remote · reason ${reason}`;
|
|
upsertPresence(node.nodeId, {
|
|
host,
|
|
ip,
|
|
version,
|
|
platform,
|
|
deviceFamily,
|
|
modelIdentifier,
|
|
mode: "remote",
|
|
reason,
|
|
lastInputSeconds: 0,
|
|
instanceId: node.nodeId,
|
|
text,
|
|
});
|
|
incrementPresenceVersion();
|
|
params.broadcast(
|
|
"presence",
|
|
{ presence: listSystemPresence() },
|
|
{
|
|
dropIfSlow: true,
|
|
stateVersion: {
|
|
presence: getPresenceVersion(),
|
|
health: getHealthVersion(),
|
|
},
|
|
},
|
|
);
|
|
};
|
|
|
|
const startNodePresenceTimer = (node: { nodeId: string }) => {
|
|
stopNodePresenceTimer(node.nodeId);
|
|
nodePresenceTimers.set(
|
|
node.nodeId,
|
|
setInterval(() => {
|
|
beaconNodePresence(node, "periodic");
|
|
}, 180_000),
|
|
);
|
|
};
|
|
|
|
if (params.bridgeEnabled && params.bridgePort > 0 && params.bridgeHost) {
|
|
try {
|
|
const started = await startNodeBridgeServer({
|
|
host: params.bridgeHost,
|
|
port: params.bridgePort,
|
|
serverName: params.machineDisplayName,
|
|
canvasHostPort: params.canvasHostPort,
|
|
canvasHostHost: params.canvasHostHost,
|
|
onRequest: (nodeId, req) => params.handleBridgeRequest(nodeId, req),
|
|
onAuthenticated: async (node) => {
|
|
beaconNodePresence(node, "node-connected");
|
|
startNodePresenceTimer(node);
|
|
|
|
try {
|
|
const cfg = await loadVoiceWakeConfig();
|
|
started.sendEvent({
|
|
nodeId: node.nodeId,
|
|
event: "voicewake.changed",
|
|
payloadJSON: JSON.stringify({ triggers: cfg.triggers }),
|
|
});
|
|
} catch {
|
|
// Best-effort only.
|
|
}
|
|
},
|
|
onDisconnected: (node) => {
|
|
params.bridgeUnsubscribeAll(node.nodeId);
|
|
stopNodePresenceTimer(node.nodeId);
|
|
beaconNodePresence(node, "node-disconnected");
|
|
},
|
|
onEvent: params.handleBridgeEvent,
|
|
onPairRequested: (request) => {
|
|
params.broadcast("node.pair.requested", request, {
|
|
dropIfSlow: true,
|
|
});
|
|
},
|
|
});
|
|
if (started.port > 0) {
|
|
params.logBridge.info(
|
|
`listening on tcp://${params.bridgeHost}:${started.port} (node)`,
|
|
);
|
|
return { bridge: started, nodePresenceTimers };
|
|
}
|
|
} catch (err) {
|
|
params.logBridge.warn(`failed to start: ${String(err)}`);
|
|
}
|
|
} else if (
|
|
params.bridgeEnabled &&
|
|
params.bridgePort > 0 &&
|
|
!params.bridgeHost
|
|
) {
|
|
params.logBridge.warn(
|
|
"bind policy requested tailnet IP, but no tailnet interface was found; refusing to start bridge",
|
|
);
|
|
}
|
|
|
|
return { bridge: null, nodePresenceTimers };
|
|
}
|