feat: pilot agent outbound-mode for remote nodes (#667)

* feat: pilot agent outbound-mode for remote nodes

Adds a second mode for managing remote nodes: the agent dials an outbound
WebSocket tunnel to the primary, so the remote host no longer needs an
inbound port, a reachable URL, or its own TLS certificate. Works behind
NAT, residential routers, and corporate firewalls.

The primary multiplexes HTTP and WebSocket requests over a single tunnel
via a hybrid JSON + binary frame protocol, bridged through a per-tunnel
loopback server so existing proxy and upgrade handlers route pilot-mode
nodes identically to proxy-mode ones.

Enrollment uses a single-use 15-minute pilot_enroll JWT exchanged for a
long-lived pilot_tunnel credential on first connect. Proxy mode continues
to work unchanged and both modes are supported side-by-side.

* test(e2e): switch to proxy mode before asserting api_url field

Remote nodes default to Pilot Agent mode, which hides the api_url input.
The SSRF-validation tests need proxy mode, so the helper now selects
Distributed API Proxy after picking Remote type before asserting the
field is visible.

* fix(e2e): wire Combobox id prop so node-mode selector resolves

The Combobox trigger button had no id, leaving its Label orphaned and
making getByRole name-based lookups fail. Adding id to the primitive,
passing id="node-mode" from NodeManager, and updating the E2E helper to
use #node-mode fixes both the a11y regression and the CI timeout.
This commit is contained in:
Anso
2026-04-17 20:31:43 -04:00
committed by GitHub
parent 0a536ae653
commit 8e7a567f69
15 changed files with 1812 additions and 27 deletions
+78 -6
View File
@@ -26,16 +26,28 @@ export interface StackAlert {
last_fired_at?: number;
}
export type NodeMode = 'proxy' | 'pilot_agent';
export interface Node {
id: number;
name: string;
type: 'local' | 'remote';
mode: NodeMode;
compose_dir: string;
is_default: boolean;
status: 'online' | 'offline' | 'unknown';
created_at: number;
api_url?: string;
api_token?: string;
pilot_last_seen?: number | null;
pilot_agent_version?: string | null;
}
export interface PilotEnrollment {
node_id: number;
token_hash: string;
expires_at: number;
used_at: number | null;
}
export interface Label {
@@ -483,6 +495,14 @@ export class DatabaseService {
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS pilot_enrollments (
node_id INTEGER PRIMARY KEY,
token_hash TEXT NOT NULL,
expires_at INTEGER NOT NULL,
used_at INTEGER,
FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS system_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
@@ -798,6 +818,11 @@ export class DatabaseService {
maybeAddCol('nodes', 'api_url', "TEXT DEFAULT ''");
maybeAddCol('nodes', 'api_token', "TEXT DEFAULT ''");
// Pilot Agent outbound-mode columns
maybeAddCol('nodes', 'mode', "TEXT NOT NULL DEFAULT 'proxy'");
maybeAddCol('nodes', 'pilot_last_seen', 'INTEGER');
maybeAddCol('nodes', 'pilot_agent_version', 'TEXT');
// Scheduled operations migrations
maybeAddCol('scheduled_task_runs', 'triggered_by', "TEXT NOT NULL DEFAULT 'scheduler'");
maybeAddCol('scheduled_tasks', 'prune_targets', 'TEXT DEFAULT NULL');
@@ -1278,36 +1303,42 @@ export class DatabaseService {
return {
...row,
is_default: row.is_default === 1,
mode: (row.mode === 'pilot_agent' ? 'pilot_agent' : 'proxy') as NodeMode,
api_token: row.api_token ? crypto.decrypt(row.api_token) : '',
pilot_last_seen: row.pilot_last_seen ?? null,
pilot_agent_version: row.pilot_agent_version ?? null,
};
}
private static readonly NODE_COLUMNS =
'id, name, type, compose_dir, is_default, status, created_at, api_url, api_token, mode, pilot_last_seen, pilot_agent_version';
public getNodes(): Node[] {
const stmt = this.db.prepare('SELECT id, name, type, compose_dir, is_default, status, created_at, api_url, api_token FROM nodes ORDER BY is_default DESC, name ASC');
const stmt = this.db.prepare(`SELECT ${DatabaseService.NODE_COLUMNS} FROM nodes ORDER BY is_default DESC, name ASC`);
return stmt.all().map((row: any) => this.decryptNodeRow(row));
}
public getNode(id: number): Node | undefined {
const stmt = this.db.prepare('SELECT id, name, type, compose_dir, is_default, status, created_at, api_url, api_token FROM nodes WHERE id = ?');
const stmt = this.db.prepare(`SELECT ${DatabaseService.NODE_COLUMNS} FROM nodes WHERE id = ?`);
const row = stmt.get(id) as any;
if (!row) return undefined;
return this.decryptNodeRow(row);
}
public getDefaultNode(): Node | undefined {
const stmt = this.db.prepare('SELECT id, name, type, compose_dir, is_default, status, created_at, api_url, api_token FROM nodes WHERE is_default = 1 LIMIT 1');
const stmt = this.db.prepare(`SELECT ${DatabaseService.NODE_COLUMNS} FROM nodes WHERE is_default = 1 LIMIT 1`);
const row = stmt.get() as any;
if (!row) return undefined;
return this.decryptNodeRow(row);
}
public addNode(node: Omit<Node, 'id' | 'status' | 'created_at'>): number {
public addNode(node: Omit<Node, 'id' | 'status' | 'created_at' | 'mode'> & { mode?: NodeMode }): number {
if (node.is_default) {
this.db.prepare('UPDATE nodes SET is_default = 0').run();
}
const crypto = CryptoService.getInstance();
const stmt = this.db.prepare(
'INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at, api_url, api_token) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
'INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at, api_url, api_token, mode) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
const result = stmt.run(
node.name,
@@ -1317,7 +1348,8 @@ export class DatabaseService {
'unknown',
Date.now(),
node.api_url || '',
node.api_token ? crypto.encrypt(node.api_token) : ''
node.api_token ? crypto.encrypt(node.api_token) : '',
node.mode || 'proxy'
);
return result.lastInsertRowid as number;
}
@@ -1343,6 +1375,9 @@ export class DatabaseService {
fields.push('api_token = ?');
values.push(updates.api_token ? CryptoService.getInstance().encrypt(updates.api_token) : '');
}
if (updates.mode !== undefined) { fields.push('mode = ?'); values.push(updates.mode); }
if (updates.pilot_last_seen !== undefined) { fields.push('pilot_last_seen = ?'); values.push(updates.pilot_last_seen); }
if (updates.pilot_agent_version !== undefined) { fields.push('pilot_agent_version = ?'); values.push(updates.pilot_agent_version); }
if (fields.length === 0) return;
@@ -1370,6 +1405,43 @@ export class DatabaseService {
this.db.prepare('UPDATE nodes SET status = ? WHERE id = ?').run(status, id);
}
// --- Pilot enrollments ---
public getPilotEnrollment(nodeId: number): PilotEnrollment | undefined {
const row = this.db.prepare(
'SELECT node_id, token_hash, expires_at, used_at FROM pilot_enrollments WHERE node_id = ?'
).get(nodeId) as PilotEnrollment | undefined;
return row;
}
public createPilotEnrollment(nodeId: number, tokenHash: string, expiresAt: number): void {
this.db.prepare(
`INSERT INTO pilot_enrollments (node_id, token_hash, expires_at, used_at)
VALUES (?, ?, ?, NULL)
ON CONFLICT(node_id) DO UPDATE SET
token_hash = excluded.token_hash,
expires_at = excluded.expires_at,
used_at = NULL`
).run(nodeId, tokenHash, expiresAt);
}
public consumePilotEnrollment(tokenHash: string): PilotEnrollment | undefined {
const now = Date.now();
return this.db.transaction(() => {
const row = this.db.prepare(
`SELECT node_id, token_hash, expires_at, used_at FROM pilot_enrollments
WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?`
).get(tokenHash, now) as PilotEnrollment | undefined;
if (!row) return undefined;
this.db.prepare('UPDATE pilot_enrollments SET used_at = ? WHERE node_id = ?').run(now, row.node_id);
return { ...row, used_at: now };
})();
}
public deletePilotEnrollment(nodeId: number): void {
this.db.prepare('DELETE FROM pilot_enrollments WHERE node_id = ?').run(nodeId);
}
// --- Stack Update Status ---
public upsertStackUpdateStatus(nodeId: number, stackName: string, hasUpdate: boolean, checkedAt: number): void {