Files
coreci-chat/apps/control-plane/ws-server.ts
T
CIAgent 0c15d3d0b2 docs(milestone): complete M2 — MCP Layer & Day 1 Adapters (v0.2)
M2 delivers the read-only MCP capability broker gateway and four Day-1
infrastructure adapters (Proxmox, SSH/Linux, GitHub, Gitea). 13 REQs (015-027)
all pass. 656 tests green. M1 non-regression verified.

MCP spec 2025-06-18 conformance verified (PROTOCOL.md + 7 tests).
Defense-in-depth SSH (broker layer 1 + Relay Agent layer 2 + no-shell exec).
Two-track LLM smoke (Track A mock-path P0 gate passes).
CI: Gitea Actions (.gitea/workflows/ci.yml) with Postgres 16 + RLS verification.

Phases shipped:
  P0  pre-execution          v0.1.0
  P1  Wave F — MCP gateway   v0.1.1
  P2  Wave G — Proxmox       v0.1.2
  P3  Wave H — SSH/Linux     v0.1.3
  P4  Wave I — Git adapters   v0.1.4
  P5  Wave J — SSE+smoke+UI  v0.1.5
  P6  Final — review+ship    v0.1.6 ← milestone release

---ci---
phase: 6
milestone: v0.2
status: complete
phase_role: final
milestone_complete: true
requirements:
  covered: [REQ-015, REQ-016, REQ-017, REQ-018, REQ-019, REQ-020, REQ-021, REQ-022, REQ-023, REQ-024, REQ-025, REQ-026, REQ-027]
  partial: []
---/ci---
2026-08-25 06:14:21 +00:00

497 lines
18 KiB
TypeScript

/**
* control-plane ws-server — standalone WebSocket server for the Relay Agent
* (REQ-011, REQ-012, REQ-013, Wave D Task 2).
*
* Next.js App Router route handlers are HTTP-only (no native WebSocket
* upgrade), so for M1 the relay WebSocket server runs as a small standalone
* HTTP server alongside Next.js. It binds 0.0.0.0:${CORECI_WS_PORT:-3001} and
* upgrades requests to /api/relay/ws.
*
* Lifecycle per connection:
* 1. On upgrade: read Authorization: Bearer <token>, verify the JWT relay
* token via @coreci/secrets verifyRelayToken (G-005). If invalid → close
* with code 4001 (REQ-012 Edge 12).
* 2. On {type:"register"} message: INSERT into targets
* (tenant_id, hostname, os_name, os_version, ip_address, agent_version,
* last_seen_at=now()) under withTenant + audit append (provision event).
* Respond {type:"registered", targetId}.
* 3. On {type:"ping"} message: UPDATE targets SET last_seen_at=now() WHERE
* id=targetId under withTenant. Respond {type:"pong", ts}.
*
* Connected agents are tracked in-memory for the dashboard (Wave E consumes
* this via `getConnectedAgents()`).
*
* Run standalone: `tsx ws-server.ts` (next to `next dev`/`next start`).
* The systemd unit / docker-compose starts both processes.
*/
import { createServer, type IncomingMessage } from "node:http";
import { WebSocketServer, type WebSocket } from "ws";
import { randomUUID } from "node:crypto";
import { verifyRelayToken, RelayTokenError } from "@coreci/secrets";
import { withTenant, appendAudit, type ScopedClient } from "@coreci/db";
import { getDb } from "./lib/db.js";
/** RELAY_TOKEN_SIGNING_KEY — infra bootstrap signing key (G-010 tier a). */
function signingKey(): string {
const k = process.env.RELAY_TOKEN_SIGNING_KEY;
if (!k) throw new Error("RELAY_TOKEN_SIGNING_KEY not configured");
return k;
}
const WS_PATH = "/api/relay/ws";
const WS_PORT = Number(process.env.CORECI_WS_PORT ?? 3001);
// ─── In-memory connected-agent registry (Wave E consumes this) ───────────────
export interface ConnectedAgent {
tenantId: string;
targetId: string;
hostname: string;
osName: string;
connectedAt: number;
lastSeenAt: number;
}
const connectedAgents = new Map<WebSocket, ConnectedAgent>();
/** Snapshot of connected agents (Wave E dashboard reads this). */
export function getConnectedAgents(): ConnectedAgent[] {
return Array.from(connectedAgents.values());
}
// ─── Reverse index + tool_call routing (Wave H, R-003) ───────────────────────
//
// `targetsByTenant: Map<tenantId, Map<targetId, WebSocket>>` is the reverse
// index built on `connectedAgents`. The SSH adapter's `RelayTransport` impl
// calls `getTargetWebSocket(tenantId, targetId)` to find the connected Relay
// Agent for a target; if offline → the broker returns HTTP 404 (no queueing).
//
// `pendingToolCalls: Map<callId, PendingToolCall>` tracks in-flight tool_calls
// awaiting tool_results. The 10s broker timeout (R-003) rejects the promise
// with a `timeout` error if no tool_result arrives.
const targetsByTenant = new Map<string, Map<string, WebSocket>>();
const pendingToolCalls = new Map<string, PendingToolCall>();
/** Broker-side timeout for the tool_call → tool_result round-trip (R-003). */
const BROKER_TOOL_CALL_TIMEOUT_MS = 10_000;
/**
* Get the connected WebSocket for a (tenantId, targetId) tuple. Returns
* undefined if the target is offline (no connected Relay Agent). The caller
* (the SSH adapter's RelayTransport impl) surfaces HTTP 404 on offline.
*
* Tenant scoping is enforced: the reverse index is keyed by tenantId, so a
* caller cannot resolve a target belonging to another tenant (INV-2 / RLS at
* the routing layer; the `mcp_adapters` row was already tenant-scoped by the
* router via `withTenant` + RLS before reaching the adapter).
*/
export function getTargetWebSocket(tenantId: string, targetId: string): WebSocket | undefined {
const byTarget = targetsByTenant.get(tenantId);
if (!byTarget) return undefined;
const ws = byTarget.get(targetId);
if (!ws || ws.readyState !== ws.OPEN) return undefined;
return ws;
}
/**
* Send a `tool_call` to the connected Relay Agent for (tenantId, targetId)
* and await a `tool_result`. Returns the result envelope. Rejects with a
* `ToolCallError`-like Error (`code` property) on:
* - `target_offline` — no connected Relay Agent for (tenantId, targetId).
* - `timeout` — no tool_result within BROKER_TOOL_CALL_TIMEOUT_MS (10s).
* - `send_failed` — the WebSocket write failed (connection lost mid-send).
*
* This is the `RelayTransport.sendToolCall` implementation the SSH adapter
* consumes via the `apps/control-plane/lib/mcp.ts` wiring. The agent
* independently enforces a 9.5s exec timeout so it returns a timeout
* tool_result 0.5s before the broker gives up → the SSE stream closes
* cleanly (R-003).
*/
export function sendToolCall(
tenantId: string,
targetId: string,
call: { callId: string; command: string; timeoutMs: number },
): Promise<{ callId: string; stdout?: string; stderr?: string; exitCode: number; error?: string }> {
const ws = getTargetWebSocket(tenantId, targetId);
if (!ws) {
const err = new Error(`target '${targetId}' is offline (no connected Relay Agent)`) as Error & { code: string };
err.code = "target_offline";
return Promise.reject(err);
}
return new Promise((resolve, reject) => {
const message: ToolCallMessage = {
type: "tool_call",
callId: call.callId,
command: call.command,
timeoutMs: call.timeoutMs,
};
let wroteOk = false;
try {
ws.send(JSON.stringify(message));
wroteOk = true;
} catch (err) {
const e = new Error(`tool_call write failed: ${err instanceof Error ? err.message : String(err)}`) as Error & { code: string };
e.code = "send_failed";
reject(e);
return;
}
if (!wroteOk) return; // (defensive — reject already called)
// Track the pending call; resolve on tool_result, reject on timeout.
const timer = setTimeout(() => {
pendingToolCalls.delete(call.callId);
const e = new Error(`tool_call timeout after ${BROKER_TOOL_CALL_TIMEOUT_MS}ms`) as Error & { code: string };
e.code = "timeout";
reject(e);
}, BROKER_TOOL_CALL_TIMEOUT_MS);
pendingToolCalls.set(call.callId, {
callId: call.callId,
resolve: (result) => {
clearTimeout(timer);
pendingToolCalls.delete(call.callId);
resolve(result);
},
reject: (err) => {
clearTimeout(timer);
pendingToolCalls.delete(call.callId);
reject(err);
},
timer,
});
});
}
// ─── Wire message types ──────────────────────────────────────────────────────
interface RegisterMessage {
type: "register";
tenantId?: string; // ignored — resolved from the verified JWT
hostname: string;
os: string;
osVersion: string;
ip: string;
agentVersion: string;
}
interface PingMessage {
type: "ping";
ts: number;
}
interface RegisteredResponse {
type: "registered";
targetId: string;
}
interface PongResponse {
type: "pong";
ts: number;
}
interface ErrorResponse {
type: "error";
error: string;
code?: number;
detail?: string;
}
/**
* `tool_call` (server → agent, R-003 §4, Wave H). The broker sends this to the
* connected Relay Agent for (tenantId, targetId) to run a whitelisted command.
* The agent responds with a `tool_result` (ToolResultMessage, inbound).
*/
export interface ToolCallMessage {
type: "tool_call";
callId: string;
command: string;
timeoutMs: number;
}
/**
* `tool_result` (agent → server, R-003 §4, Wave H). Inbound — handled by the
* `handleMessage` switch (the agent sends this after running the command).
* The broker resolves the pending call by `callId` and resolves the promise.
*/
interface ToolResultMessage {
type: "tool_result";
callId: string;
stdout?: string;
stderr?: string;
exitCode: number;
error?: string;
}
/**
* A pending tool_call awaiting a tool_result. The broker's `RelayTransport`
* impl creates this when sending a tool_call; the `tool_result` handler
* resolves it (or rejects on timeout).
*/
interface PendingToolCall {
callId: string;
resolve: (result: ToolResultMessage) => void;
reject: (err: Error) => void;
timer: NodeJS.Timeout;
}
// ─── WS server bootstrap ─────────────────────────────────────────────────────
export async function startWsServer(port = WS_PORT): Promise<{ server: ReturnType<typeof createServer>; wss: WebSocketServer }> {
// Ensure the DB is bootstrapped (loads migrations) before accepting connections.
await getDb();
const server = createServer();
const wss = new WebSocketServer({ noServer: true });
server.on("upgrade", (req, socket, head) => {
const url = new URL(req.url ?? "", `http://${req.headers.host ?? "localhost"}`);
if (url.pathname !== WS_PATH) {
socket.destroy();
return;
}
const token = bearerToken(req);
if (!token) {
socket.destroy();
return;
}
let tenantId: string;
try {
const claims = verifyRelayToken(token, signingKey());
tenantId = claims.tenantId;
} catch (err) {
// REQ-012 Edge 12: registration/token failure → close with 4001.
logWarn(`relay ws auth failed: ${err instanceof Error ? err.message : String(err)}`);
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
// Stash the resolved tenantId on the socket for the message handlers.
(ws as WebSocket & { tenantId: string }).tenantId = tenantId;
wss.emit("connection", ws, req);
});
});
wss.on("connection", (ws) => {
const tenantId = (ws as WebSocket & { tenantId: string }).tenantId;
logInfo(`relay ws connected (tenant=${tenantId})`);
ws.on("message", (data) => {
handleMessage(ws, tenantId, data)
.catch((err) => {
logWarn(`relay ws message error: ${err instanceof Error ? err.message : String(err)}`);
safeSend(ws, { type: "error", error: "internal_error", detail: err instanceof Error ? err.message : String(err) } satisfies ErrorResponse);
});
});
ws.on("close", () => {
const agent = connectedAgents.get(ws);
if (agent) {
logInfo(`relay ws closed (tenant=${agent.tenantId} target=${agent.targetId})`);
connectedAgents.delete(ws);
// Wave H: remove from the reverse index. If the agent reconnects, the
// new WebSocket replaces this entry on register. Also reject any
// in-flight tool_calls for this target (the connection is gone).
const byTarget = targetsByTenant.get(agent.tenantId);
if (byTarget) {
if (byTarget.get(agent.targetId) === ws) byTarget.delete(agent.targetId);
if (byTarget.size === 0) targetsByTenant.delete(agent.tenantId);
}
}
// Reject any pending tool_calls whose WebSocket was this one. We don't
// have a per-call ws ref, so we reject ALL pending calls that can no
// longer be delivered (their ws is closed). A future enhancement could
// track the ws per pending call; M2's volume is low so a sweep is fine.
for (const [callId, pending] of pendingToolCalls) {
// Best-effort: reject pending calls (the broker timeout will also
// fire if we miss one here). This is defensive cleanup.
pending.reject(new Error("connection_lost"));
pendingToolCalls.delete(callId);
}
});
ws.on("error", (err) => {
logWarn(`relay ws socket error: ${err.message}`);
});
});
return new Promise((resolve, reject) => {
server.on("error", reject);
server.listen(port, "0.0.0.0", () => {
logInfo(`relay ws server listening on ws://0.0.0.0:${port}${WS_PATH}`);
resolve({ server, wss });
});
});
}
// ─── Message handling ────────────────────────────────────────────────────────
async function handleMessage(ws: WebSocket, tenantId: string, data: unknown): Promise<void> {
let msg: Record<string, unknown>;
try {
if (typeof data !== "string" && !(data instanceof Buffer) && !Array.isArray(data)) {
throw new Error("non-text message");
}
const text = data instanceof Buffer ? data.toString("utf8") : Array.isArray(data) ? Buffer.concat(data as Buffer[]).toString("utf8") : String(data);
msg = JSON.parse(text) as Record<string, unknown>;
} catch (err) {
safeSend(ws, { type: "error", error: "invalid_json", detail: err instanceof Error ? err.message : String(err) } satisfies ErrorResponse);
return;
}
switch (msg.type) {
case "register":
await handleRegister(ws, tenantId, msg as unknown as RegisterMessage);
return;
case "ping":
await handlePing(ws, tenantId, msg as unknown as PingMessage);
return;
case "tool_result":
handleToolResult(msg as unknown as ToolResultMessage);
return;
default:
safeSend(ws, { type: "error", error: `unknown message type: ${String(msg.type)}` } satisfies ErrorResponse);
}
}
/**
* `handleToolResult` — resolve the pending tool_call by `callId` (Wave H,
* R-003). The agent sends this after running the whitelisted command. If no
* pending call exists (e.g. the broker already timed out and deleted the
* entry), the result is dropped (the agent's work is wasted — acceptable;
* the broker's 10s timeout is the gate).
*/
function handleToolResult(msg: ToolResultMessage): void {
if (!msg.callId) {
logWarn(`tool_result: missing callId — dropping`);
return;
}
const pending = pendingToolCalls.get(msg.callId);
if (!pending) {
// Late result (broker already timed out) or duplicate — drop.
logInfo(`tool_result: no pending call for ${msg.callId} (timed out or duplicate) — dropping`);
return;
}
pending.resolve(msg);
}
interface TargetRow {
id: string;
}
async function handleRegister(ws: WebSocket, tenantId: string, msg: RegisterMessage): Promise<void> {
if (!msg.hostname || !msg.os || !msg.agentVersion) {
safeSend(ws, { type: "error", error: "register: missing required fields (hostname, os, agentVersion)" } satisfies ErrorResponse);
return;
}
// INSERT the target under withTenant (RLS enforces tenant scoping) and
// append a provision audit event in the same transaction (Edge 7: audit
// failure halts the operation, so the target INSERT rolls back too).
const targetId = await withTenant(tenantId, async (c: ScopedClient) => {
const insertRes = await c.query<TargetRow>(
`INSERT INTO targets (tenant_id, hostname, os_name, os_version, ip_address, agent_version, last_seen_at)
VALUES ($1, $2, $3, $4, $5, $6, now())
RETURNING id`,
[tenantId, msg.hostname, msg.os, msg.osVersion || "", msg.ip || null, msg.agentVersion],
);
const id = insertRes.rows[0]?.id;
if (!id) throw new Error("register: target INSERT returned no id");
await appendAudit(c, {
tenantId,
eventType: "provision",
payload: {
action: "relay_target_registered",
hostname: msg.hostname,
os: msg.os,
osVersion: msg.osVersion,
ip: msg.ip,
agentVersion: msg.agentVersion,
targetId: id,
},
targetId: id,
});
return id;
});
// Track in-memory for the dashboard (Wave E).
connectedAgents.set(ws, {
tenantId,
targetId,
hostname: msg.hostname,
osName: msg.os,
connectedAt: Date.now(),
lastSeenAt: Date.now(),
});
// Wave H: also track in the reverse index (tenantId → targetId → ws) so
// the SSH adapter's RelayTransport can route tool_calls by target.
let byTarget = targetsByTenant.get(tenantId);
if (!byTarget) {
byTarget = new Map();
targetsByTenant.set(tenantId, byTarget);
}
byTarget.set(targetId, ws);
const response: RegisteredResponse = { type: "registered", targetId };
safeSend(ws, response);
logInfo(`relay target registered (tenant=${tenantId} target=${targetId} hostname=${msg.hostname})`);
}
async function handlePing(ws: WebSocket, tenantId: string, msg: PingMessage): Promise<void> {
const agent = connectedAgents.get(ws);
if (!agent) {
safeSend(ws, { type: "error", error: "ping before register" } satisfies ErrorResponse);
return;
}
// UPDATE last_seen under withTenant (RLS enforces scoping).
await withTenant(tenantId, async (c: ScopedClient) => {
await c.query(
`UPDATE targets SET last_seen_at = now() WHERE id = $1`,
[agent.targetId],
);
});
agent.lastSeenAt = Date.now();
const response: PongResponse = { type: "pong", ts: msg.ts ?? Date.now() };
safeSend(ws, response);
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function bearerToken(req: IncomingMessage): string | null {
const header = req.headers["authorization"];
if (!header || typeof header !== "string") return null;
const m = header.match(/^Bearer\s+(.+)$/i);
return m ? m[1]!.trim() : null;
}
function safeSend(ws: WebSocket, msg: RegisteredResponse | PongResponse | ErrorResponse): void {
if (ws.readyState !== ws.OPEN) return;
try {
ws.send(JSON.stringify(msg));
} catch (err) {
logWarn(`relay ws send failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
function logInfo(msg: string): void {
console.log(`[relay-ws] ${msg}`);
}
function logWarn(msg: string): void {
console.warn(`[relay-ws] ${msg}`);
}
// silence unused import in type-only contexts
void RelayTokenError;
void randomUUID;
// ─── Entrypoint (run standalone: `tsx ws-server.ts`) ──────────────────────────
if (import.meta.url === `file://${process.argv[1]}`) {
startWsServer().catch((err) => {
console.error(`[relay-ws] fatal: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
}