control: seed events, add tests, update remote doc
This commit is contained in:
@@ -13,7 +13,7 @@ This repo supports “remote over SSH” by keeping a single relay (the master)
|
|||||||
1) Establish SSH tunnel.
|
1) Establish SSH tunnel.
|
||||||
2) Open TCP socket to the local forwarded port.
|
2) Open TCP socket to the local forwarded port.
|
||||||
3) Send `ping` to verify connectivity.
|
3) Send `ping` to verify connectivity.
|
||||||
4) Issue `health` and `last-heartbeat` requests to seed UI.
|
4) Issue `health`, `status`, and `last-heartbeat` requests to seed UI.
|
||||||
5) Listen for `event` frames (heartbeat updates, relay status).
|
5) Listen for `event` frames (heartbeat updates, relay status).
|
||||||
|
|
||||||
## Heartbeats
|
## Heartbeats
|
||||||
@@ -25,9 +25,13 @@ This repo supports “remote over SSH” by keeping a single relay (the master)
|
|||||||
- The menu app skips SSH and connects directly to `127.0.0.1:18789` with the same protocol.
|
- The menu app skips SSH and connects directly to `127.0.0.1:18789` with the same protocol.
|
||||||
|
|
||||||
## Failure handling
|
## Failure handling
|
||||||
- If the tunnel drops, the client reconnects and re-issues `ping`, `health`, and `last-heartbeat` to refresh state.
|
- If the tunnel drops, the client reconnects and re-issues `ping`, `health`, and `last-heartbeat` to refresh state (the mac app shows “Control channel disconnected”).
|
||||||
- If the control port is unavailable (older relay), the app can optionally fall back to the legacy CLI path, but the goal is to rely solely on the control channel.
|
- If the control port is unavailable (older relay), the app can optionally fall back to the legacy CLI path, but the goal is to rely solely on the control channel.
|
||||||
|
|
||||||
|
## Test Remote (in the mac app)
|
||||||
|
1) SSH reachability check (`ssh -o BatchMode=yes … echo ok`).
|
||||||
|
2) If SSH succeeds, the app opens the control tunnel and issues a `health` request; success marks the remote as ready.
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
- Control server listens only on localhost.
|
- Control server listens only on localhost.
|
||||||
- SSH tunneling reuses existing keys/agent; no additional auth is added by the control server.
|
- SSH tunneling reuses existing keys/agent; no additional auth is added by the control server.
|
||||||
|
|||||||
95
src/infra/control-channel.test.ts
Normal file
95
src/infra/control-channel.test.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import net from "node:net";
|
||||||
|
|
||||||
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { startControlChannel } from "./control-channel.js";
|
||||||
|
import { emitHeartbeatEvent } from "./heartbeat-events.js";
|
||||||
|
|
||||||
|
// Mock health/status to avoid hitting real services
|
||||||
|
vi.mock("../commands/health.js", () => ({
|
||||||
|
getHealthSnapshot: vi.fn(async () => ({
|
||||||
|
ts: Date.now(),
|
||||||
|
durationMs: 10,
|
||||||
|
web: {
|
||||||
|
linked: true,
|
||||||
|
authAgeMs: 1000,
|
||||||
|
connect: { ok: true, status: 200, error: null, elapsedMs: 5 },
|
||||||
|
},
|
||||||
|
heartbeatSeconds: 60,
|
||||||
|
sessions: { path: "/tmp/sessions.json", count: 1, recent: [] },
|
||||||
|
ipc: { path: "/tmp/clawdis.sock", exists: true },
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../commands/status.js", () => ({
|
||||||
|
getStatusSummary: vi.fn(async () => ({
|
||||||
|
web: { linked: true, authAgeMs: 1000 },
|
||||||
|
heartbeatSeconds: 60,
|
||||||
|
sessions: { path: "/tmp/sessions.json", count: 1, recent: [] },
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("control channel", () => {
|
||||||
|
let server: Awaited<ReturnType<typeof startControlChannel>>;
|
||||||
|
let client: net.Socket;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = await startControlChannel({}, { port: 19999 });
|
||||||
|
client = net.createConnection({ host: "127.0.0.1", port: 19999 });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
client.destroy();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendRequest = (method: string, params?: unknown) =>
|
||||||
|
new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const frame = { type: "request", id, method, params };
|
||||||
|
client.write(`${JSON.stringify(frame)}\n`);
|
||||||
|
const onData = (chunk: Buffer) => {
|
||||||
|
const line = chunk.toString("utf8").trim();
|
||||||
|
const parsed = JSON.parse(line) as { id?: string };
|
||||||
|
if (parsed.id === id) {
|
||||||
|
client.off("data", onData);
|
||||||
|
resolve(parsed as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
client.on("data", onData);
|
||||||
|
client.on("error", reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("responds to ping", async () => {
|
||||||
|
const res = await sendRequest("ping");
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns health snapshot", async () => {
|
||||||
|
const res = await sendRequest("health");
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
const payload = res.payload as { web?: { linked?: boolean } };
|
||||||
|
expect(payload.web?.linked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits heartbeat events", async () => {
|
||||||
|
const evtPromise = new Promise<Record<string, unknown>>((resolve) => {
|
||||||
|
const handler = (chunk: Buffer) => {
|
||||||
|
const lines = chunk.toString("utf8").trim().split(/\n/);
|
||||||
|
for (const line of lines) {
|
||||||
|
const parsed = JSON.parse(line) as { type?: string; event?: string };
|
||||||
|
if (parsed.type === "event" && parsed.event === "heartbeat") {
|
||||||
|
client.off("data", handler);
|
||||||
|
resolve(parsed as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
client.on("data", handler);
|
||||||
|
});
|
||||||
|
|
||||||
|
emitHeartbeatEvent({ status: "sent", to: "+1", preview: "hi" });
|
||||||
|
const evt = await evtPromise;
|
||||||
|
expect(evt.event).toBe("heartbeat");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -54,6 +54,17 @@ export async function startControlChannel(
|
|||||||
const server = net.createServer((socket) => {
|
const server = net.createServer((socket) => {
|
||||||
socket.setEncoding("utf8");
|
socket.setEncoding("utf8");
|
||||||
clients.add(socket);
|
clients.add(socket);
|
||||||
|
|
||||||
|
// Seed relay status + last heartbeat for new clients.
|
||||||
|
write(socket, {
|
||||||
|
type: "event",
|
||||||
|
event: "relay-status",
|
||||||
|
payload: { state: "running" },
|
||||||
|
});
|
||||||
|
const last = getLastHeartbeatEvent();
|
||||||
|
if (last)
|
||||||
|
write(socket, { type: "event", event: "heartbeat", payload: last });
|
||||||
|
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
|
|
||||||
socket.on("data", (chunk) => {
|
socket.on("data", (chunk) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user