From 8e7a567f691cbb0cc047a69c1da38ad097b8d5ee Mon Sep 17 00:00:00 2001 From: Anso Date: Fri, 17 Apr 2026 20:31:43 -0400 Subject: [PATCH] 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. --- backend/src/__tests__/pilot-protocol.test.ts | 121 ++++++ backend/src/index.ts | 224 +++++++++- backend/src/pilot/agent.ts | 351 ++++++++++++++++ backend/src/pilot/protocol.ts | 213 ++++++++++ backend/src/services/DatabaseService.ts | 84 +++- backend/src/services/NodeRegistry.ts | 51 ++- backend/src/services/PilotTunnelBridge.ts | 385 ++++++++++++++++++ backend/src/services/PilotTunnelManager.ts | 114 ++++++ docs/docs.json | 1 + docs/features/pilot-agent.mdx | 96 +++++ docs/images/pilot-agent/enrollment-dialog.png | Bin 0 -> 50486 bytes e2e/nodes.spec.ts | 3 + frontend/src/components/NodeManager.tsx | 188 ++++++++- frontend/src/components/ui/combobox.tsx | 3 + frontend/src/context/NodeContext.tsx | 5 + 15 files changed, 1812 insertions(+), 27 deletions(-) create mode 100644 backend/src/__tests__/pilot-protocol.test.ts create mode 100644 backend/src/pilot/agent.ts create mode 100644 backend/src/pilot/protocol.ts create mode 100644 backend/src/services/PilotTunnelBridge.ts create mode 100644 backend/src/services/PilotTunnelManager.ts create mode 100644 docs/features/pilot-agent.mdx create mode 100644 docs/images/pilot-agent/enrollment-dialog.png diff --git a/backend/src/__tests__/pilot-protocol.test.ts b/backend/src/__tests__/pilot-protocol.test.ts new file mode 100644 index 00000000..df35f9d1 --- /dev/null +++ b/backend/src/__tests__/pilot-protocol.test.ts @@ -0,0 +1,121 @@ +/** + * Tests for the pilot tunnel wire protocol. + */ +import { describe, it, expect } from 'vitest'; +import { + BinaryFrameType, + PROTOCOL_VERSION, + StreamIdAllocator, + decodeBinaryFrame, + decodeJsonFrame, + encodeBinaryFrame, + encodeJsonFrame, +} from '../pilot/protocol'; + +describe('JSON frame roundtrip', () => { + it('roundtrips a hello frame', () => { + const frame = encodeJsonFrame({ t: 'hello', version: PROTOCOL_VERSION, role: 'agent', agentVersion: '0.1.0' }); + const decoded = decodeJsonFrame(frame); + expect(decoded).toEqual({ t: 'hello', version: PROTOCOL_VERSION, role: 'agent', agentVersion: '0.1.0' }); + }); + + it('roundtrips an http_req frame', () => { + const raw = encodeJsonFrame({ + t: 'http_req', + s: 7, + method: 'POST', + path: '/api/stacks', + headers: { 'content-type': 'application/json', 'x-sencho-tier': 'paid' }, + }); + const decoded = decodeJsonFrame(raw); + expect(decoded.t).toBe('http_req'); + if (decoded.t !== 'http_req') throw new Error('narrowing'); + expect(decoded.s).toBe(7); + expect(decoded.method).toBe('POST'); + expect(decoded.headers['x-sencho-tier']).toBe('paid'); + }); + + it('roundtrips ws lifecycle frames', () => { + const open = decodeJsonFrame(encodeJsonFrame({ t: 'ws_open', s: 3, path: '/ws', headers: {} })); + expect(open.t).toBe('ws_open'); + const accept = decodeJsonFrame(encodeJsonFrame({ t: 'ws_accept', s: 3, headers: {} })); + expect(accept.t).toBe('ws_accept'); + const close = decodeJsonFrame(encodeJsonFrame({ t: 'ws_close', s: 3, code: 1000, reason: 'done' })); + expect(close.t).toBe('ws_close'); + }); + + it('rejects malformed JSON', () => { + expect(() => decodeJsonFrame('not json')).toThrow(); + }); + + it('rejects missing type discriminator', () => { + expect(() => decodeJsonFrame('{"foo":1}')).toThrow(/type discriminator/); + }); +}); + +describe('Binary frame roundtrip', () => { + it('roundtrips an http request body chunk', () => { + const payload = Buffer.from('hello world'); + const encoded = encodeBinaryFrame(BinaryFrameType.HttpReqBody, 42, payload); + const decoded = decodeBinaryFrame(encoded); + expect(decoded.type).toBe(BinaryFrameType.HttpReqBody); + expect(decoded.streamId).toBe(42); + expect(decoded.payload.equals(payload)).toBe(true); + }); + + it('roundtrips a ws binary message', () => { + const payload = Buffer.from([0xde, 0xad, 0xbe, 0xef]); + const encoded = encodeBinaryFrame(BinaryFrameType.WsMessageBinary, 1, payload); + const decoded = decodeBinaryFrame(encoded); + expect(decoded.type).toBe(BinaryFrameType.WsMessageBinary); + expect(decoded.streamId).toBe(1); + expect(decoded.payload.equals(payload)).toBe(true); + }); + + it('roundtrips an empty payload', () => { + const encoded = encodeBinaryFrame(BinaryFrameType.HttpResBody, 99, Buffer.alloc(0)); + const decoded = decodeBinaryFrame(encoded); + expect(decoded.payload.length).toBe(0); + expect(decoded.streamId).toBe(99); + }); + + it('handles max streamId', () => { + const encoded = encodeBinaryFrame(BinaryFrameType.HttpReqBody, 0xffffffff, Buffer.from([0])); + const decoded = decodeBinaryFrame(encoded); + expect(decoded.streamId).toBe(0xffffffff); + }); + + it('rejects negative streamId', () => { + expect(() => encodeBinaryFrame(BinaryFrameType.HttpReqBody, -1, Buffer.alloc(0))).toThrow(/streamId/); + }); + + it('rejects streamId above uint32 max', () => { + expect(() => encodeBinaryFrame(BinaryFrameType.HttpReqBody, 0x100000000, Buffer.alloc(0))).toThrow(/streamId/); + }); + + it('rejects frames shorter than the 5-byte header', () => { + expect(() => decodeBinaryFrame(Buffer.from([0x01, 0x00]))).toThrow(/too short/); + }); + + it('rejects unknown binary frame types', () => { + const buf = Buffer.alloc(5); + buf.writeUInt8(0x99, 0); + expect(() => decodeBinaryFrame(buf)).toThrow(/unknown binary frame type/); + }); +}); + +describe('StreamIdAllocator', () => { + it('allocates monotonically starting at 1', () => { + const alloc = new StreamIdAllocator(); + expect(alloc.allocate()).toBe(1); + expect(alloc.allocate()).toBe(2); + expect(alloc.allocate()).toBe(3); + }); + + it('wraps before overflowing int32', () => { + const alloc = new StreamIdAllocator(); + (alloc as unknown as { next: number }).next = 0x7fffffff; + expect(alloc.allocate()).toBe(0x7fffffff); + expect(alloc.allocate()).toBe(1); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index c89bff39..55fae5be 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -28,6 +28,8 @@ import { ImageUpdateService } from './services/ImageUpdateService'; import { templateService } from './services/TemplateService'; import { ErrorParser } from './utils/ErrorParser'; import { NodeRegistry } from './services/NodeRegistry'; +import { PilotTunnelManager } from './services/PilotTunnelManager'; +import { encodeJsonFrame as encodePilotJsonFrame, PROTOCOL_VERSION as PILOT_PROTOCOL_VERSION, PilotCloseCode } from './pilot/protocol'; import { FleetSyncService } from './services/FleetSyncService'; import { LicenseService, type LicenseTier, type LicenseVariant, isLicenseTier, isLicenseVariant, normalizeTier, normalizeVariant, PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from './services/LicenseService'; import { WebhookService } from './services/WebhookService'; @@ -503,7 +505,11 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction): // Node proxy tokens: Sencho-to-Sencho communication, not user sessions. // Handle before user resolution since proxy tokens have no username. - if (decoded.scope === 'node_proxy') { + // pilot_tunnel scope is the equivalent credential for pilot-agent-mode + // nodes; it arrives on requests the primary forwarded through a tunnel + // after the primary itself re-signed/trusted them. Same tier-header trust + // rules apply. + if (decoded.scope === 'node_proxy' || decoded.scope === 'pilot_tunnel') { req.user = { username: 'node-proxy', role: 'admin', userId: 0 }; // Distributed License Enforcement: trust tier headers only from authenticated node proxy requests. @@ -3600,6 +3606,11 @@ const server = http.createServer(app); // WebSocket server with authentication const wss = new WebSocketServer({ noServer: true }); +// Dedicated WS server for accepting inbound pilot-agent tunnels. Agents dial +// /api/pilot/tunnel; the handshake verifies a pilot_enroll or pilot_tunnel +// JWT, then hands the socket off to PilotTunnelManager. +const pilotTunnelWss = new WebSocketServer({ noServer: true }); + let terminalWs: WebSocket | null = null; // Notification push - set of authenticated browser clients subscribed to real-time alerts @@ -3614,8 +3625,110 @@ NotificationService.getInstance().setBroadcaster((notification) => { } }); +/** + * Handle a pilot-agent tunnel upgrade. Accepts either: + * - pilot_enroll (15m, one-time): consume the enrollment row, mint a + * long-lived pilot_tunnel token, send it back in a ctrl enroll_ack frame. + * - pilot_tunnel (365d): accept the socket directly. + * In both cases, the accepted WebSocket is handed to PilotTunnelManager. + */ +async function handlePilotTunnelUpgrade( + req: import('http').IncomingMessage, + socket: import('stream').Duplex, + head: Buffer, +): Promise { + const reject = (status: number, message: string) => { + try { socket.write(`HTTP/1.1 ${status} ${message}\r\n\r\n`); } catch { /* ignore */ } + try { socket.destroy(); } catch { /* ignore */ } + }; + + const authHeader = req.headers['authorization']; + const header = Array.isArray(authHeader) ? authHeader[0] : authHeader; + const token = header?.startsWith('Bearer ') ? header.slice(7) : null; + if (!token) return reject(401, 'Unauthorized'); + + const db = DatabaseService.getInstance(); + const jwtSecret = db.getGlobalSettings().auth_jwt_secret; + if (!jwtSecret) return reject(500, 'Internal Server Error'); + + let decoded: { scope?: string; nodeId?: number; enrollNonce?: string }; + try { + decoded = jwt.verify(token, jwtSecret) as typeof decoded; + } catch { + return reject(401, 'Unauthorized'); + } + + if (decoded.scope !== 'pilot_enroll' && decoded.scope !== 'pilot_tunnel') { + return reject(403, 'Forbidden'); + } + if (typeof decoded.nodeId !== 'number') return reject(400, 'Bad Request'); + + const node = db.getNode(decoded.nodeId); + if (!node || node.type !== 'remote' || node.mode !== 'pilot_agent') { + return reject(404, 'Not Found'); + } + + let mintedTunnelToken: string | null = null; + if (decoded.scope === 'pilot_enroll') { + const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); + const row = db.consumePilotEnrollment(tokenHash); + if (!row || row.node_id !== decoded.nodeId) { + return reject(401, 'Unauthorized'); + } + mintedTunnelToken = jwt.sign( + { scope: 'pilot_tunnel', nodeId: decoded.nodeId }, + jwtSecret, + { expiresIn: '365d' }, + ); + } + + const agentVersionHeader = req.headers['x-sencho-agent-version']; + const agentVersion = Array.isArray(agentVersionHeader) ? agentVersionHeader[0] : agentVersionHeader; + + pilotTunnelWss.handleUpgrade(req, socket, head, async (ws) => { + try { + ws.send(encodePilotJsonFrame({ + t: 'hello', + version: PILOT_PROTOCOL_VERSION, + role: 'primary', + })); + if (mintedTunnelToken) { + ws.send(encodePilotJsonFrame({ + t: 'ctrl', + op: 'enroll_ack', + payload: { token: mintedTunnelToken, nodeId: decoded.nodeId }, + })); + } + } catch { + try { ws.close(1011, 'hello failed'); } catch { /* ignore */ } + return; + } + + try { + await PilotTunnelManager.getInstance().registerTunnel(decoded.nodeId!, ws, agentVersion); + } catch (err) { + console.error('[Pilot] Failed to register tunnel:', (err as Error).message); + try { ws.close(1011, 'registration failed'); } catch { /* ignore */ } + } + }); +} + // Handle WebSocket upgrade with JWT authentication server.on('upgrade', async (req, socket, head) => { + // Pilot-agent tunnel ingress: agents dial /api/pilot/tunnel and present + // either a short-lived pilot_enroll token (consumed once) or a long-lived + // pilot_tunnel token. Handled independently of user/session auth because + // these are machine credentials and carry no cookies. + try { + const reqUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); + if (reqUrl.pathname === '/api/pilot/tunnel') { + await handlePilotTunnelUpgrade(req, socket, head); + return; + } + } catch { + // URL parse error falls through to the normal flow, which will reject. + } + // Parse cookies from the upgrade request const cookieHeader = req.headers.cookie || ''; const cookies = Object.fromEntries( @@ -8123,7 +8236,7 @@ app.post('/api/nodes', async (req: Request, res: Response) => { } if (!requirePermission(req, res, 'node:manage')) return; try { - const { name, type, compose_dir, is_default, api_url, api_token } = req.body; + const { name, type, compose_dir, is_default, api_url, api_token, mode } = req.body; if (!name || typeof name !== 'string') { return res.status(400).json({ error: 'Node name is required' }); @@ -8131,9 +8244,12 @@ app.post('/api/nodes', async (req: Request, res: Response) => { if (!type || !['local', 'remote'].includes(type)) { return res.status(400).json({ error: 'Node type must be "local" or "remote"' }); } - if (type === 'remote') { + + const resolvedMode: 'proxy' | 'pilot_agent' = type === 'remote' && mode === 'pilot_agent' ? 'pilot_agent' : 'proxy'; + + if (type === 'remote' && resolvedMode === 'proxy') { if (!api_url || typeof api_url !== 'string') { - return res.status(400).json({ error: 'API URL is required for remote nodes' }); + return res.status(400).json({ error: 'API URL is required for proxy-mode remote nodes' }); } const urlCheck = isValidRemoteUrl(api_url); if (!urlCheck.valid) { @@ -8146,18 +8262,25 @@ app.post('/api/nodes', async (req: Request, res: Response) => { type, compose_dir: compose_dir || '/app/compose', is_default: is_default || false, - api_url: api_url || '', - api_token: api_token || '', + api_url: resolvedMode === 'pilot_agent' ? '' : (api_url || ''), + api_token: resolvedMode === 'pilot_agent' ? '' : (api_token || ''), + mode: resolvedMode, }); // Notify subscribers (e.g. DockerEventManager) so a new local node gets // its event stream spun up immediately, not on next restart. NodeRegistry.getInstance().notifyNodeAdded(id); - const isPlainHttp = type === 'remote' && api_url && api_url.startsWith('http://'); + let enrollment: ReturnType | null = null; + if (resolvedMode === 'pilot_agent') { + enrollment = mintPilotEnrollment(id, req); + } + + const isPlainHttp = resolvedMode === 'proxy' && type === 'remote' && api_url && api_url.startsWith('http://'); res.json({ success: true, id, + ...(enrollment && { enrollment }), ...(isPlainHttp && { warning: 'This node uses plain HTTP. Use HTTPS or a VPN for connections over the public internet.' }) @@ -8171,6 +8294,77 @@ app.post('/api/nodes', async (req: Request, res: Response) => { } }); +/** + * Mint a fresh 15-minute, single-use enrollment token for a pilot-mode node. + * Stores a sha256 hash in pilot_enrollments so the token itself is never + * recoverable from the DB. The returned `dockerRun` string is a copy-paste + * starter command the admin runs on the remote host. + */ +function mintPilotEnrollment(nodeId: number, req: Request): { token: string; expiresAt: number; dockerRun: string } { + const db = DatabaseService.getInstance(); + const jwtSecret = db.getGlobalSettings().auth_jwt_secret; + if (!jwtSecret) throw new Error('JWT secret not configured'); + + const ttlSeconds = 15 * 60; + const expiresAt = Date.now() + ttlSeconds * 1000; + const enrollNonce = crypto.randomUUID(); + const token = jwt.sign( + { scope: 'pilot_enroll', nodeId, enrollNonce }, + jwtSecret, + { expiresIn: ttlSeconds }, + ); + const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); + db.createPilotEnrollment(nodeId, tokenHash, expiresAt); + + const forwardedProto = req.headers['x-forwarded-proto']; + const protoHeader = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto; + const protocol = protoHeader || req.protocol || 'http'; + const host = req.get('host') || 'localhost:3000'; + const primaryUrl = `${protocol}://${host}`; + + const dockerRun = + `docker run -d --restart=unless-stopped --name sencho-agent ` + + `-v /var/run/docker.sock:/var/run/docker.sock ` + + `-v sencho-agent-data:/app/data ` + + `-v /opt/docker/sencho:/app/compose ` + + `-e SENCHO_MODE=pilot ` + + `-e SENCHO_PRIMARY_URL=${primaryUrl} ` + + `-e SENCHO_ENROLL_TOKEN=${token} ` + + `saelix/sencho:latest`; + + return { token, expiresAt, dockerRun }; +} + +// Regenerate an enrollment token for an existing pilot-mode node. Used when +// the first token expired before the agent was started, or when the agent +// container was lost and needs to re-enroll from scratch. +app.post('/api/nodes/:id/pilot/enroll', async (req: Request, res: Response) => { + if (req.apiTokenScope) { + res.status(403).json({ error: 'API tokens cannot manage nodes.', code: 'SCOPE_DENIED' }); + return; + } + const nodeIdStr = req.params.id as string; + if (!requirePermission(req, res, 'node:manage', 'node', nodeIdStr)) return; + try { + const nodeId = parseInt(nodeIdStr, 10); + if (!Number.isFinite(nodeId)) { + return res.status(400).json({ error: 'Invalid node id' }); + } + const node = DatabaseService.getInstance().getNode(nodeId); + if (!node) return res.status(404).json({ error: 'Node not found' }); + if (node.type !== 'remote' || node.mode !== 'pilot_agent') { + return res.status(400).json({ error: 'Enrollment only applies to pilot-agent nodes' }); + } + // Close any existing tunnel so the re-enrolling agent cleanly replaces it. + PilotTunnelManager.getInstance().closeTunnel(nodeId, PilotCloseCode.EnrollmentRegenerated, 'enrollment regenerated'); + const enrollment = mintPilotEnrollment(nodeId, req); + res.json({ success: true, enrollment }); + } catch (error: any) { + console.error('Failed to regenerate pilot enrollment:', error); + res.status(500).json({ error: error.message || 'Failed to regenerate enrollment' }); + } +}); + // Update a node app.put('/api/nodes/:id', async (req: Request, res: Response) => { if (req.apiTokenScope) { @@ -8376,8 +8570,20 @@ async function startServer() { }, MFA_REPLAY_PURGE_INTERVAL_MS); mfaReplayPurgeTimer.unref(); - server.listen(PORT, () => { - console.log(`Server running on port ${PORT}`); + // Pilot-agent mode: bind only to loopback so no external port is exposed. + // All traffic is demultiplexed from the primary via the pilot tunnel. + const isPilotAgent = process.env.SENCHO_MODE === 'pilot'; + const listenHost = isPilotAgent ? '127.0.0.1' : undefined; + + server.listen(PORT, listenHost, () => { + console.log(`Server running on ${listenHost || '0.0.0.0'}:${PORT}${isPilotAgent ? ' (pilot-agent mode)' : ''}`); + if (isPilotAgent) { + // Start the outbound tunnel client once the local HTTP server is ready + // to accept loopback traffic from the tunnel. + import('./pilot/agent').then((m) => m.startPilotAgent(PORT)).catch((err) => { + console.error('[Pilot] Agent startup failed:', err); + }); + } }); } diff --git a/backend/src/pilot/agent.ts b/backend/src/pilot/agent.ts new file mode 100644 index 00000000..10da8653 --- /dev/null +++ b/backend/src/pilot/agent.ts @@ -0,0 +1,351 @@ +import fs from 'fs'; +import path from 'path'; +import http from 'http'; +import WebSocket from 'ws'; +import { getSenchoVersion } from '../services/CapabilityRegistry'; +import { + BinaryFrameType, + PROTOCOL_VERSION, + decodeBinaryFrame, + decodeJsonFrame, + encodeBinaryFrame, + encodeJsonFrame, + wsDataToBuffer, + wsDataToString, +} from './protocol'; + +const RECONNECT_MIN_MS = 1_000; +const RECONNECT_MAX_MS = 60_000; +const PING_INTERVAL_MS = 30_000; +const TOKEN_PATH = path.join(process.env.DATA_DIR || '/app/data', 'pilot.jwt'); + +/** + * Pilot agent: dials the primary via outbound WebSocket and tunnels every + * inbound frame to the agent's own loopback HTTP server (the fully-booted + * Sencho app). Because the tunnel is the only ingress, the agent needs no + * open port, no TLS certificate, and no reachable address. + */ +export function startPilotAgent(loopbackPort: number): void { + const primaryUrl = process.env.SENCHO_PRIMARY_URL; + if (!primaryUrl) { + console.error('[Pilot] SENCHO_PRIMARY_URL is required when SENCHO_MODE=pilot'); + process.exit(1); + } + + const enrollToken = process.env.SENCHO_ENROLL_TOKEN; + const persistedToken = readPersistedToken(); + + if (!enrollToken && !persistedToken) { + console.error('[Pilot] SENCHO_ENROLL_TOKEN is required on first boot'); + process.exit(1); + } + + const agent = new PilotAgent({ + primaryUrl, + loopbackPort, + initialToken: persistedToken || enrollToken!, + enrolling: !persistedToken, + }); + agent.start(); +} + +interface AgentOptions { + primaryUrl: string; + loopbackPort: number; + initialToken: string; + enrolling: boolean; +} + +class PilotAgent { + private readonly options: AgentOptions; + private token: string; + private backoff = RECONNECT_MIN_MS; + private ws: WebSocket | null = null; + private pingTimer?: NodeJS.Timeout; + private reconnectTimer?: NodeJS.Timeout; + private readonly httpStreams = new Map(); + private readonly wsStreams = new Map(); + private shuttingDown = false; + private readonly agentVersion: string; + + constructor(options: AgentOptions) { + this.options = options; + this.token = options.initialToken; + this.agentVersion = getSenchoVersion() || '0.0.0'; + } + + public start(): void { + this.connect(); + process.on('SIGTERM', () => this.shutdown()); + process.on('SIGINT', () => this.shutdown()); + } + + private shutdown(): void { + this.shuttingDown = true; + if (this.pingTimer) clearInterval(this.pingTimer); + if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = undefined; } + try { this.ws?.close(1000, 'agent shutdown'); } catch { /* ignore */ } + } + + private connect(): void { + if (this.shuttingDown) return; + + const wsUrl = this.options.primaryUrl.replace(/^http/, 'ws').replace(/\/$/, '') + '/api/pilot/tunnel'; + const ws = new WebSocket(wsUrl, { + headers: { + Authorization: `Bearer ${this.token}`, + 'x-sencho-agent-version': this.agentVersion, + }, + handshakeTimeout: 15_000, + }); + this.ws = ws; + + ws.on('open', () => { + console.log('[Pilot] Tunnel connected to', this.options.primaryUrl); + this.backoff = RECONNECT_MIN_MS; + try { + ws.send(encodeJsonFrame({ + t: 'hello', + version: PROTOCOL_VERSION, + role: 'agent', + agentVersion: this.agentVersion, + })); + } catch (err) { + console.error('[Pilot] Failed to send hello:', (err as Error).message); + } + this.pingTimer = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + try { ws.ping(); } catch { /* surfaced via error */ } + } + }, PING_INTERVAL_MS); + }); + + ws.on('message', (data, isBinary) => this.handleFrame(data, isBinary)); + ws.on('close', (code, reason) => { + console.log('[Pilot] Tunnel closed:', code, reason?.toString?.() ?? ''); + this.cleanupAfterDisconnect(); + this.scheduleReconnect(); + }); + ws.on('error', (err) => { + console.warn('[Pilot] Tunnel error:', err.message); + // 'close' will follow; reconnect is scheduled there. + }); + } + + private cleanupAfterDisconnect(): void { + if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = undefined; } + for (const [, entry] of this.httpStreams) { + try { entry.req.destroy(); } catch { /* ignore */ } + } + this.httpStreams.clear(); + for (const [, ws] of this.wsStreams) { + try { ws.close(1006, 'tunnel closed'); } catch { /* ignore */ } + } + this.wsStreams.clear(); + } + + private scheduleReconnect(): void { + if (this.shuttingDown) return; + const jitter = Math.floor(Math.random() * 500); + const delay = this.backoff + jitter; + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = undefined; + this.connect(); + }, delay); + this.backoff = Math.min(this.backoff * 2, RECONNECT_MAX_MS); + } + + private handleFrame(data: unknown, isBinary: boolean): void { + try { + if (isBinary) { + const buf = wsDataToBuffer(data); + if (!buf) return; + this.handleBinaryFrame(decodeBinaryFrame(buf)); + } else { + const text = wsDataToString(data); + if (text == null) return; + const frame = decodeJsonFrame(text); + this.handleJsonFrame(frame); + } + } catch (err) { + console.warn('[Pilot] Malformed frame from primary:', (err as Error).message); + } + } + + private handleJsonFrame(frame: ReturnType): void { + const ws = this.ws; + if (!ws) return; + switch (frame.t) { + case 'hello': { + if (frame.version !== PROTOCOL_VERSION) { + console.error(`[Pilot] Protocol version ${frame.version} from primary is incompatible with agent (${PROTOCOL_VERSION}); exiting.`); + this.shuttingDown = true; + try { ws.close(1002, 'incompatible version'); } catch { /* ignore */ } + process.exit(1); + } + break; + } + case 'ctrl': { + if (frame.op === 'enroll_ack' && frame.payload && typeof frame.payload.token === 'string') { + this.token = frame.payload.token; + persistToken(this.token); + console.log('[Pilot] Enrollment complete; long-lived token persisted.'); + } + break; + } + case 'http_req': this.onHttpReq(frame); break; + case 'http_req_end': this.onHttpReqEnd(frame.s); break; + case 'ws_open': this.onWsOpen(frame); break; + case 'ws_msg_text': this.onWsMsgText(frame.s, frame.data); break; + case 'ws_close': this.onWsClose(frame.s, frame.code, frame.reason); break; + default: + // Other frame types are primary-bound only; agent ignores. + break; + } + } + + private handleBinaryFrame(frame: ReturnType): void { + switch (frame.type) { + case BinaryFrameType.HttpReqBody: { + const entry = this.httpStreams.get(frame.streamId); + if (!entry) return; + try { entry.req.write(frame.payload); } catch { /* ignore */ } + break; + } + case BinaryFrameType.WsMessageBinary: { + const ws = this.wsStreams.get(frame.streamId); + if (!ws) return; + try { ws.send(frame.payload, { binary: true }); } catch { /* ignore */ } + break; + } + default: + break; + } + } + + // --- HTTP dispatch (tunnel -> loopback) --- + + private onHttpReq(frame: Extract, { t: 'http_req' }>): void { + const ws = this.ws; + if (!ws) return; + + const req = http.request({ + host: '127.0.0.1', + port: this.options.loopbackPort, + method: frame.method, + path: frame.path, + headers: { ...frame.headers, host: `127.0.0.1:${this.options.loopbackPort}` }, + }, (res) => { + const outHeaders: Record = {}; + for (const [k, v] of Object.entries(res.headers)) { + if (typeof v === 'string') outHeaders[k] = v; + else if (Array.isArray(v)) outHeaders[k] = v.join(', '); + } + try { + ws.send(encodeJsonFrame({ + t: 'http_res', + s: frame.s, + status: res.statusCode || 200, + headers: outHeaders, + })); + } catch { /* ignore */ } + + res.on('data', (chunk: Buffer) => { + try { ws.send(encodeBinaryFrame(BinaryFrameType.HttpResBody, frame.s, chunk), { binary: true }); } catch { /* ignore */ } + }); + res.on('end', () => { + try { ws.send(encodeJsonFrame({ t: 'http_res_end', s: frame.s })); } catch { /* ignore */ } + this.httpStreams.delete(frame.s); + }); + res.on('error', () => { + try { ws.send(encodeJsonFrame({ t: 'http_err', s: frame.s, code: 'bad_response', message: 'upstream error' })); } catch { /* ignore */ } + this.httpStreams.delete(frame.s); + }); + }); + + req.on('error', (err) => { + try { + ws.send(encodeJsonFrame({ + t: 'http_err', + s: frame.s, + code: 'agent_error', + message: err.message || 'agent request failed', + })); + } catch { /* ignore */ } + this.httpStreams.delete(frame.s); + }); + + this.httpStreams.set(frame.s, { req }); + } + + private onHttpReqEnd(streamId: number): void { + const entry = this.httpStreams.get(streamId); + if (!entry) return; + try { entry.req.end(); } catch { /* ignore */ } + } + + // --- WebSocket dispatch (tunnel -> loopback) --- + + private onWsOpen(frame: Extract, { t: 'ws_open' }>): void { + const ws = this.ws; + if (!ws) return; + + const target = `ws://127.0.0.1:${this.options.loopbackPort}${frame.path}`; + const client = new WebSocket(target, { + headers: { ...frame.headers, host: `127.0.0.1:${this.options.loopbackPort}` }, + }); + + client.on('open', () => { + try { ws.send(encodeJsonFrame({ t: 'ws_accept', s: frame.s, headers: {} })); } catch { /* ignore */ } + this.wsStreams.set(frame.s, client); + }); + client.on('message', (data, isBinary) => { + if (isBinary) { + try { ws.send(encodeBinaryFrame(BinaryFrameType.WsMessageBinary, frame.s, wsDataToBuffer(data) ?? Buffer.alloc(0)), { binary: true }); } catch { /* ignore */ } + } else { + try { ws.send(encodeJsonFrame({ t: 'ws_msg_text', s: frame.s, data: wsDataToString(data) ?? '' })); } catch { /* ignore */ } + } + }); + client.on('close', (code, reason) => { + try { ws.send(encodeJsonFrame({ t: 'ws_close', s: frame.s, code, reason: reason?.toString?.() })); } catch { /* ignore */ } + this.wsStreams.delete(frame.s); + }); + client.on('error', () => { + try { ws.send(encodeJsonFrame({ t: 'ws_reject', s: frame.s, status: 502, message: 'agent websocket failed' })); } catch { /* ignore */ } + this.wsStreams.delete(frame.s); + }); + } + + private onWsMsgText(streamId: number, data: string): void { + const ws = this.wsStreams.get(streamId); + if (!ws) return; + try { ws.send(data); } catch { /* ignore */ } + } + + private onWsClose(streamId: number, code: number, reason?: string): void { + const ws = this.wsStreams.get(streamId); + if (!ws) return; + try { ws.close(code, reason); } catch { /* ignore */ } + this.wsStreams.delete(streamId); + } +} + +function readPersistedToken(): string | null { + try { + if (fs.existsSync(TOKEN_PATH)) { + return fs.readFileSync(TOKEN_PATH, 'utf8').trim() || null; + } + } catch { /* ignore */ } + return null; +} + +function persistToken(token: string): void { + try { + const dir = path.dirname(TOKEN_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(TOKEN_PATH, token, { mode: 0o600 }); + } catch (err) { + console.warn('[Pilot] Failed to persist tunnel token:', (err as Error).message); + } +} + diff --git a/backend/src/pilot/protocol.ts b/backend/src/pilot/protocol.ts new file mode 100644 index 00000000..47e11444 --- /dev/null +++ b/backend/src/pilot/protocol.ts @@ -0,0 +1,213 @@ +/** + * Pilot Tunnel wire protocol. + * + * A single WebSocket between primary and pilot agent carries many multiplexed + * HTTP requests and nested WebSocket streams. Each stream is identified by a + * monotonically increasing streamId allocated by the primary (the originator + * of every request). + * + * Wire format is hybrid: + * - Text frames: JSON envelopes for metadata (open/close/headers/control) + * - Binary frames: raw payload bytes with a 5-byte prefix + * [ 1 byte: BinaryFrameType ][ 4 bytes: streamId (big-endian) ][ bytes... ] + * + * JSON is inspectable and low-overhead for small control messages; binary + * avoids base64 bloat on body chunks and WS message payloads. + */ + +export const PROTOCOL_VERSION = 1; + +// --- Binary frame types (first byte of a binary WS frame) --- + +export enum BinaryFrameType { + HttpReqBody = 0x01, + HttpResBody = 0x02, + WsMessageBinary = 0x03, +} + +// --- JSON envelope types --- + +export type JsonFrame = + | HelloFrame + | HttpReqFrame + | HttpReqEndFrame + | HttpResFrame + | HttpResEndFrame + | HttpErrorFrame + | WsOpenFrame + | WsAcceptFrame + | WsRejectFrame + | WsMessageTextFrame + | WsCloseFrame + | ControlFrame; + +export interface HelloFrame { + t: 'hello'; + version: number; + role: 'primary' | 'agent'; + agentVersion?: string; +} + +export interface HttpReqFrame { + t: 'http_req'; + s: number; + method: string; + path: string; + headers: Record; +} + +export interface HttpReqEndFrame { + t: 'http_req_end'; + s: number; +} + +export interface HttpResFrame { + t: 'http_res'; + s: number; + status: number; + headers: Record; +} + +export interface HttpResEndFrame { + t: 'http_res_end'; + s: number; +} + +export interface HttpErrorFrame { + t: 'http_err'; + s: number; + code: 'timeout' | 'tunnel_down' | 'bad_response' | 'agent_error'; + message: string; +} + +export interface WsOpenFrame { + t: 'ws_open'; + s: number; + path: string; + headers: Record; +} + +export interface WsAcceptFrame { + t: 'ws_accept'; + s: number; + headers: Record; +} + +export interface WsRejectFrame { + t: 'ws_reject'; + s: number; + status: number; + message: string; +} + +export interface WsMessageTextFrame { + t: 'ws_msg_text'; + s: number; + data: string; +} + +export interface WsCloseFrame { + t: 'ws_close'; + s: number; + code: number; + reason?: string; +} + +export interface ControlFrame { + t: 'ctrl'; + op: 'enroll_ack' | 'node_info' | 'ping' | 'pong'; + payload?: Record; +} + +// --- Serialize / parse --- + +export function encodeJsonFrame(frame: JsonFrame): string { + return JSON.stringify(frame); +} + +export function decodeJsonFrame(raw: string): JsonFrame { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || typeof parsed.t !== 'string') { + throw new Error('invalid frame: missing type discriminator'); + } + return parsed as JsonFrame; +} + +/** + * Build a binary frame: [type][streamId BE][payload]. + * Returns a fresh Buffer owned by the caller. + */ +export function encodeBinaryFrame(type: BinaryFrameType, streamId: number, payload: Buffer): Buffer { + if (!Number.isInteger(streamId) || streamId < 0 || streamId > 0xffffffff) { + throw new Error(`invalid streamId: ${streamId}`); + } + const out = Buffer.allocUnsafe(5 + payload.length); + out.writeUInt8(type, 0); + out.writeUInt32BE(streamId, 1); + payload.copy(out, 5); + return out; +} + +export interface DecodedBinaryFrame { + type: BinaryFrameType; + streamId: number; + payload: Buffer; +} + +export function decodeBinaryFrame(buf: Buffer): DecodedBinaryFrame { + if (buf.length < 5) { + throw new Error(`binary frame too short: ${buf.length} bytes`); + } + const type = buf.readUInt8(0) as BinaryFrameType; + if (type !== BinaryFrameType.HttpReqBody && + type !== BinaryFrameType.HttpResBody && + type !== BinaryFrameType.WsMessageBinary) { + throw new Error(`unknown binary frame type: ${type}`); + } + const streamId = buf.readUInt32BE(1); + const payload = buf.subarray(5); + return { type, streamId, payload }; +} + +// --- WS payload normalization --- + +export function wsDataToBuffer(data: unknown): Buffer | null { + if (Buffer.isBuffer(data)) return data; + if (data instanceof ArrayBuffer) return Buffer.from(data); + if (Array.isArray(data)) return Buffer.concat(data.map((d) => Buffer.isBuffer(d) ? d : Buffer.from(d as ArrayBuffer))); + return null; +} + +export function wsDataToString(data: unknown): string | null { + if (typeof data === 'string') return data; + const buf = wsDataToBuffer(data); + return buf ? buf.toString('utf8') : null; +} + +// --- Close codes --- + +export const PilotCloseCode = { + Replaced: 4000, + EnrollmentRegenerated: 4001, + ProtocolError: 1002, +} as const; + +// --- Stream id allocation --- + +/** + * Monotonic stream id generator. Primary is the sole allocator. + * Wraps at 2^31 (well above practical per-tunnel concurrency). + */ +export class StreamIdAllocator { + private next: number; + + constructor(start = 1) { + this.next = start; + } + + allocate(): number { + const id = this.next; + this.next = this.next >= 0x7fffffff ? 1 : this.next + 1; + return id; + } +} diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 3495219c..c5b9b921 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -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): number { + public addNode(node: Omit & { 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 { diff --git a/backend/src/services/NodeRegistry.ts b/backend/src/services/NodeRegistry.ts index 6bf66df5..fbc5b110 100644 --- a/backend/src/services/NodeRegistry.ts +++ b/backend/src/services/NodeRegistry.ts @@ -3,6 +3,7 @@ import axios from 'axios'; import { EventEmitter } from 'events'; import { DatabaseService, Node } from './DatabaseService'; import { fetchRemoteMeta } from './CapabilityRegistry'; +import { PilotTunnelManager } from './PilotTunnelManager'; /** * NodeRegistry: Manages connections for multiple nodes. @@ -99,12 +100,22 @@ export class NodeRegistry extends EventEmitter { /** * Get the HTTP proxy target for a remote node. * Returns { apiUrl, apiToken } for use by the HTTP proxy middleware. + * + * Pilot-agent nodes resolve to the loopback URL of their active tunnel + * bridge; the bridge strips the bearer token and re-authenticates + * implicitly via the pre-verified tunnel socket. */ public getProxyTarget(nodeId: number): { apiUrl: string; apiToken: string } | null { const node = DatabaseService.getInstance().getNode(nodeId); - if (!node || node.type !== 'remote' || !node.api_url || !node.api_token) { - return null; + if (!node || node.type !== 'remote') return null; + + if (node.mode === 'pilot_agent') { + const loopbackUrl = PilotTunnelManager.getInstance().getLoopbackUrl(nodeId); + if (!loopbackUrl) return null; + return { apiUrl: loopbackUrl, apiToken: '' }; } + + if (!node.api_url || !node.api_token) return null; return { apiUrl: node.api_url, apiToken: node.api_token }; } @@ -122,12 +133,48 @@ export class NodeRegistry extends EventEmitter { } if (node.type === 'remote') { + if (node.mode === 'pilot_agent') { + return this.testPilotConnection(node); + } return this.testRemoteConnection(node); } return this.testLocalConnection(nodeId); } + /** + * Check whether a pilot-agent node has an active tunnel. Does not call + * any endpoint; tunnel liveness is tracked in-process by + * PilotTunnelManager and via the JWT handshake that originally accepted + * the agent. + */ + private async testPilotConnection(node: Node): Promise<{ success: boolean; error?: string; info?: any }> { + const db = DatabaseService.getInstance(); + const active = PilotTunnelManager.getInstance().hasActiveTunnel(node.id); + if (!active) { + db.updateNodeStatus(node.id, 'offline'); + return { success: false, error: 'Pilot agent is not connected. Start the agent container or regenerate the enrollment token.' }; + } + db.updateNodeStatus(node.id, 'online'); + return { + success: true, + info: { + name: node.name, + serverVersion: 'Pilot Agent', + senchoVersion: node.pilot_agent_version ?? null, + capabilities: [], + os: 'Remote (tunnel)', + architecture: 'Remote', + containers: '-', + containersRunning: '-', + images: '-', + memTotal: 0, + cpus: '-', + pilotLastSeen: node.pilot_last_seen ?? null, + }, + }; + } + private async testLocalConnection(nodeId: number): Promise<{ success: boolean; error?: string; info?: any }> { const db = DatabaseService.getInstance(); try { diff --git a/backend/src/services/PilotTunnelBridge.ts b/backend/src/services/PilotTunnelBridge.ts new file mode 100644 index 00000000..6e96478d --- /dev/null +++ b/backend/src/services/PilotTunnelBridge.ts @@ -0,0 +1,385 @@ +import http, { IncomingMessage, Server as HttpServer, ServerResponse } from 'http'; +import { Socket } from 'net'; +import { EventEmitter } from 'events'; +import { WebSocket, WebSocketServer } from 'ws'; +import { + BinaryFrameType, + DecodedBinaryFrame, + StreamIdAllocator, + decodeBinaryFrame, + decodeJsonFrame, + encodeBinaryFrame, + encodeJsonFrame, + wsDataToBuffer, + wsDataToString, +} from '../pilot/protocol'; + +const BUFFER_HIGH_WATER_MARK = 4 * 1024 * 1024; +const PING_INTERVAL_MS = 30_000; + +interface HttpStreamState { + kind: 'http'; + res: ServerResponse; + headersWritten: boolean; +} + +interface WsStreamState { + kind: 'ws'; + rawSocket?: Socket; + rawHead?: Buffer; + upgradeRequest: IncomingMessage; + clientWs?: WebSocket; +} + +type StreamState = HttpStreamState | WsStreamState; + +/** + * Per-tunnel bridge: hosts a loopback HTTP server that demuxes requests into + * wire frames sent over the pilot WebSocket, and remuxes response frames back + * to the loopback caller. + * + * The primary's existing http-proxy-middleware setup treats the loopback URL + * as just another remote target, so HTTP and WebSocket proxy logic, header + * stripping/injection, and license-tier propagation all work unchanged. + */ +export class PilotTunnelBridge extends EventEmitter { + private readonly tunnelWs: WebSocket; + private readonly loopback: HttpServer; + private readonly wsUpgradeServer: WebSocketServer; + private readonly streamIds = new StreamIdAllocator(); + private readonly streams = new Map(); + private readonly connectedAt = Date.now(); + private loopbackUrl = ''; + private pingTimer?: NodeJS.Timeout; + private closed = false; + + constructor(_nodeId: number, tunnelWs: WebSocket) { + super(); + this.tunnelWs = tunnelWs; + this.loopback = http.createServer(); + this.wsUpgradeServer = new WebSocketServer({ noServer: true }); + + this.loopback.on('request', (req, res) => this.handleLoopbackRequest(req, res)); + this.loopback.on('upgrade', (req, socket, head) => this.handleLoopbackUpgrade(req, socket as Socket, head)); + this.loopback.on('clientError', (_err, socket) => { + try { socket.destroy(); } catch { /* ignore */ } + }); + + this.tunnelWs.on('message', (data, isBinary) => this.handleTunnelMessage(data, isBinary)); + this.tunnelWs.on('close', () => this.onTunnelClose()); + this.tunnelWs.on('error', () => this.onTunnelClose()); + } + + public async start(): Promise { + await new Promise((resolve, reject) => { + const onError = (err: Error) => reject(err); + this.loopback.once('error', onError); + this.loopback.listen(0, '127.0.0.1', () => { + const addr = this.loopback.address(); + if (!addr || typeof addr === 'string') { + reject(new Error('loopback server returned unexpected address')); + return; + } + this.loopbackUrl = `http://127.0.0.1:${addr.port}`; + this.loopback.removeListener('error', onError); + resolve(); + }); + }); + this.pingTimer = setInterval(() => { + if (this.tunnelWs.readyState === WebSocket.OPEN) { + try { this.tunnelWs.ping(); } catch { /* surfaced via 'error' */ } + } + }, PING_INTERVAL_MS); + } + + public getLoopbackUrl(): string { return this.loopbackUrl; } + public getConnectedAt(): number { return this.connectedAt; } + + public close(code = 1000, reason = 'closed by primary'): void { + if (this.closed) return; + this.closed = true; + if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = undefined; } + + for (const [, state] of this.streams) this.teardownStream(state); + this.streams.clear(); + + try { this.tunnelWs.close(code, reason); } catch { /* ignore */ } + try { this.loopback.close(); } catch { /* ignore */ } + try { this.wsUpgradeServer.close(); } catch { /* ignore */ } + this.emit('closed'); + } + + // --- Loopback HTTP ingress --- + + private handleLoopbackRequest(req: IncomingMessage, res: ServerResponse): void { + if (this.closed || this.tunnelWs.readyState !== WebSocket.OPEN) { + res.statusCode = 502; + res.end('pilot tunnel not ready'); + return; + } + + const streamId = this.streamIds.allocate(); + this.streams.set(streamId, { kind: 'http', res, headersWritten: false }); + + const headers: Record = {}; + for (const [k, v] of Object.entries(req.headers)) { + if (typeof v === 'string') headers[k] = v; + else if (Array.isArray(v)) headers[k] = v.join(', '); + } + + this.sendJson({ + t: 'http_req', + s: streamId, + method: req.method || 'GET', + path: req.url || '/', + headers, + }); + + req.on('data', (chunk: Buffer) => { + if (!this.streams.has(streamId)) return; + this.sendBinary(BinaryFrameType.HttpReqBody, streamId, chunk); + if (this.tunnelWs.bufferedAmount > BUFFER_HIGH_WATER_MARK) req.pause(); + }); + req.on('end', () => { + if (!this.streams.has(streamId)) return; + this.sendJson({ t: 'http_req_end', s: streamId }); + }); + req.on('error', () => { + const s = this.streams.get(streamId); + if (s) this.teardownStream(s); + this.streams.delete(streamId); + }); + + res.on('close', () => { + // Client disconnected before response finished. + if (this.streams.has(streamId)) { + this.streams.delete(streamId); + this.sendJson({ t: 'http_err', s: streamId, code: 'tunnel_down', message: 'client aborted' }); + } + }); + } + + private handleLoopbackUpgrade(req: IncomingMessage, socket: Socket, head: Buffer): void { + if (this.closed || this.tunnelWs.readyState !== WebSocket.OPEN) { + socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); + socket.destroy(); + return; + } + + const streamId = this.streamIds.allocate(); + this.streams.set(streamId, { + kind: 'ws', + rawSocket: socket, + rawHead: head, + upgradeRequest: req, + }); + + const headers: Record = {}; + for (const [k, v] of Object.entries(req.headers)) { + if (typeof v === 'string') headers[k] = v; + else if (Array.isArray(v)) headers[k] = v.join(', '); + } + + this.sendJson({ + t: 'ws_open', + s: streamId, + path: req.url || '/', + headers, + }); + + socket.on('error', () => { + const s = this.streams.get(streamId); + if (s) this.teardownStream(s); + this.streams.delete(streamId); + }); + socket.on('close', () => { + if (this.streams.has(streamId)) { + this.sendJson({ t: 'ws_close', s: streamId, code: 1006, reason: 'client closed' }); + this.streams.delete(streamId); + } + }); + } + + // --- Tunnel ingress (frames from agent) --- + + private handleTunnelMessage(data: unknown, isBinary: boolean): void { + if (this.closed) return; + try { + if (isBinary) { + const buf = wsDataToBuffer(data); + if (!buf) return; + this.handleBinaryFrame(decodeBinaryFrame(buf)); + } else { + const text = wsDataToString(data); + if (text == null) return; + const frame = decodeJsonFrame(text); + this.handleJsonFrame(frame); + } + } catch { + // Malformed frame: kill the tunnel to force re-sync. + this.close(1002, 'protocol error'); + } + } + + private handleJsonFrame(frame: ReturnType): void { + switch (frame.t) { + case 'http_res': { + const s = this.streams.get(frame.s); + if (!s || s.kind !== 'http') return; + if (!s.headersWritten) { + try { + s.res.writeHead(frame.status, frame.headers); + } catch { /* headers already sent or invalid */ } + s.headersWritten = true; + } + break; + } + case 'http_res_end': { + const s = this.streams.get(frame.s); + if (!s || s.kind !== 'http') return; + try { s.res.end(); } catch { /* ignore */ } + this.streams.delete(frame.s); + break; + } + case 'http_err': { + const s = this.streams.get(frame.s); + if (!s) return; + if (s.kind === 'http' && !s.headersWritten) { + try { + s.res.writeHead(502, { 'content-type': 'text/plain' }); + s.res.end(`pilot tunnel error: ${frame.code} ${frame.message}`); + } catch { /* ignore */ } + } else { + this.teardownStream(s); + } + this.streams.delete(frame.s); + break; + } + case 'ws_accept': { + const s = this.streams.get(frame.s); + if (!s || s.kind !== 'ws' || !s.rawSocket || !s.rawHead) return; + this.wsUpgradeServer.handleUpgrade(s.upgradeRequest, s.rawSocket, s.rawHead, (ws) => { + s.clientWs = ws; + s.rawSocket = undefined; + s.rawHead = undefined; + ws.on('message', (msg, isBin) => { + if (isBin) { + this.sendBinary(BinaryFrameType.WsMessageBinary, frame.s, wsDataToBuffer(msg) ?? Buffer.alloc(0)); + } else { + this.sendJson({ t: 'ws_msg_text', s: frame.s, data: wsDataToString(msg) ?? '' }); + } + }); + ws.on('close', (code, reason) => { + if (this.streams.has(frame.s)) { + this.sendJson({ t: 'ws_close', s: frame.s, code, reason: reason?.toString?.() }); + this.streams.delete(frame.s); + } + }); + ws.on('error', () => { + if (this.streams.has(frame.s)) this.streams.delete(frame.s); + }); + }); + break; + } + case 'ws_reject': { + const s = this.streams.get(frame.s); + if (!s || s.kind !== 'ws' || !s.rawSocket) return; + try { + s.rawSocket.write(`HTTP/1.1 ${frame.status} ${frame.message}\r\n\r\n`); + s.rawSocket.destroy(); + } catch { /* ignore */ } + this.streams.delete(frame.s); + break; + } + case 'ws_msg_text': { + const s = this.streams.get(frame.s); + if (!s || s.kind !== 'ws' || !s.clientWs) return; + try { s.clientWs.send(frame.data); } catch { /* ignore */ } + break; + } + case 'ws_close': { + const s = this.streams.get(frame.s); + if (!s || s.kind !== 'ws') return; + if (s.clientWs) { + try { s.clientWs.close(frame.code, frame.reason); } catch { /* ignore */ } + } else if (s.rawSocket) { + try { s.rawSocket.destroy(); } catch { /* ignore */ } + } + this.streams.delete(frame.s); + break; + } + case 'ctrl': { + // Primary-side bridge does not act on control ops today; the + // upgrade handler consumes enroll_ack before registerTunnel is + // called, and ping/pong are handled by the WS layer. + break; + } + default: + // Ignore unknown JSON frame types for forward compatibility. + break; + } + } + + private handleBinaryFrame(frame: DecodedBinaryFrame): void { + const s = this.streams.get(frame.streamId); + if (!s) return; + switch (frame.type) { + case BinaryFrameType.HttpResBody: { + if (s.kind !== 'http') return; + if (!s.headersWritten) { + // Agent sent body before headers; synthesize 200 so we don't drop data. + try { s.res.writeHead(200); } catch { /* ignore */ } + s.headersWritten = true; + } + try { s.res.write(frame.payload); } catch { /* ignore */ } + break; + } + case BinaryFrameType.WsMessageBinary: { + if (s.kind !== 'ws' || !s.clientWs) return; + try { s.clientWs.send(frame.payload, { binary: true }); } catch { /* ignore */ } + break; + } + case BinaryFrameType.HttpReqBody: + // Agent never originates request bodies; ignore for defense-in-depth. + break; + default: + break; + } + } + + private onTunnelClose(): void { + if (this.closed) return; + this.close(1006, 'tunnel closed'); + } + + // --- Helpers --- + + private sendJson(frame: Parameters[0]): void { + if (this.tunnelWs.readyState !== WebSocket.OPEN) return; + try { this.tunnelWs.send(encodeJsonFrame(frame)); } catch { /* ignore */ } + } + + private sendBinary(type: BinaryFrameType, streamId: number, payload: Buffer): void { + if (this.tunnelWs.readyState !== WebSocket.OPEN) return; + try { this.tunnelWs.send(encodeBinaryFrame(type, streamId, payload), { binary: true }); } catch { /* ignore */ } + } + + private teardownStream(state: StreamState): void { + if (state.kind === 'http') { + try { + if (!state.headersWritten) { + state.res.writeHead(502, { 'content-type': 'text/plain' }); + state.res.end('pilot tunnel closed'); + } else { + state.res.end(); + } + } catch { /* ignore */ } + } else { + if (state.clientWs) { + try { state.clientWs.close(1011, 'tunnel closed'); } catch { /* ignore */ } + } else if (state.rawSocket) { + try { state.rawSocket.destroy(); } catch { /* ignore */ } + } + } + } +} diff --git a/backend/src/services/PilotTunnelManager.ts b/backend/src/services/PilotTunnelManager.ts new file mode 100644 index 00000000..503edad0 --- /dev/null +++ b/backend/src/services/PilotTunnelManager.ts @@ -0,0 +1,114 @@ +import { EventEmitter } from 'events'; +import WebSocket from 'ws'; +import { PilotTunnelBridge } from './PilotTunnelBridge'; +import { DatabaseService } from './DatabaseService'; +import { PilotCloseCode } from '../pilot/protocol'; + +/** + * PilotTunnelManager: singleton registry of active pilot tunnels. + * + * Each enrolled pilot-agent node holds one outbound WebSocket to the primary. + * For every such tunnel we spin up a local loopback HTTP server that demuxes + * requests into frames. Remote-proxy code paths (http-proxy-middleware and the + * WebSocket upgrade handler) can then treat pilot nodes identically to standard + * proxy nodes by pointing at the loopback URL. + * + * Events: + * - 'tunnel-up' (nodeId: number) after a tunnel is accepted + * - 'tunnel-down' (nodeId: number) after a tunnel closes (for any reason) + */ +export class PilotTunnelManager extends EventEmitter { + private static instance: PilotTunnelManager; + private bridges: Map = new Map(); + + private constructor() { + super(); + this.setMaxListeners(50); + } + + public static getInstance(): PilotTunnelManager { + if (!PilotTunnelManager.instance) { + PilotTunnelManager.instance = new PilotTunnelManager(); + } + return PilotTunnelManager.instance; + } + + /** + * Accept a newly handshaked pilot tunnel. Replaces any prior tunnel for the + * same node (split-brain prevention): the previous bridge is closed + * before the new one is installed. + * + * Resolves once the loopback HTTP server is listening. + */ + public async registerTunnel(nodeId: number, ws: WebSocket, agentVersion?: string): Promise { + const existing = this.bridges.get(nodeId); + if (existing) { + existing.close(PilotCloseCode.Replaced, 'replaced by newer tunnel'); + this.bridges.delete(nodeId); + } + + const bridge = new PilotTunnelBridge(nodeId, ws); + bridge.once('closed', () => { + if (this.bridges.get(nodeId) === bridge) { + this.bridges.delete(nodeId); + DatabaseService.getInstance().updateNodeStatus(nodeId, 'offline'); + this.emit('tunnel-down', nodeId); + } + }); + await bridge.start(); + + this.bridges.set(nodeId, bridge); + const db = DatabaseService.getInstance(); + db.updateNodeStatus(nodeId, 'online'); + db.updateNode(nodeId, { + pilot_last_seen: Date.now(), + pilot_agent_version: agentVersion ?? null, + }); + this.emit('tunnel-up', nodeId); + } + + /** + * Return the loopback base URL (http://127.0.0.1:PORT) for a node's active + * tunnel, or null if no tunnel is currently registered. + */ + public getLoopbackUrl(nodeId: number): string | null { + const bridge = this.bridges.get(nodeId); + return bridge ? bridge.getLoopbackUrl() : null; + } + + /** + * True if a tunnel for this node is registered and healthy. + */ + public hasActiveTunnel(nodeId: number): boolean { + return this.bridges.has(nodeId); + } + + /** + * Force-close a tunnel (e.g., on node deletion). + */ + public closeTunnel(nodeId: number, code = 1000, reason = 'closed by primary'): void { + const bridge = this.bridges.get(nodeId); + if (!bridge) return; + bridge.close(code, reason); + this.bridges.delete(nodeId); + } + + /** + * Snapshot of currently active tunnels. + */ + public listActive(): Array<{ nodeId: number; loopbackUrl: string; connectedAt: number }> { + return Array.from(this.bridges.entries()).map(([nodeId, bridge]) => ({ + nodeId, + loopbackUrl: bridge.getLoopbackUrl(), + connectedAt: bridge.getConnectedAt(), + })); + } + + /** + * Record an application-level heartbeat from the agent. + */ + public touch(nodeId: number): void { + if (!this.bridges.has(nodeId)) return; + DatabaseService.getInstance().updateNode(nodeId, { pilot_last_seen: Date.now() }); + } +} diff --git a/docs/docs.json b/docs/docs.json index 89c58747..8ae35aba 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -102,6 +102,7 @@ "features/global-observability", "features/host-console", "features/multi-node", + "features/pilot-agent", "features/fleet-view", "features/fleet-sync", "features/remote-updates", diff --git a/docs/features/pilot-agent.mdx b/docs/features/pilot-agent.mdx new file mode 100644 index 00000000..60bdbde2 --- /dev/null +++ b/docs/features/pilot-agent.mdx @@ -0,0 +1,96 @@ +--- +title: Pilot Agent +description: Add remote nodes behind NAT, residential networks, or corporate firewalls without exposing any inbound port. +--- + +Pilot Agent mode connects a remote server to your primary Sencho instance through a single outbound WebSocket tunnel. Every request the primary sends to that node, HTTP or WebSocket, rides through this tunnel. The remote host never opens an inbound port and never needs a TLS certificate. + +## When to use Pilot Agent + +Pick Pilot Agent when the remote host: +- Sits behind NAT or a residential router. +- Lives on a corporate network that blocks inbound connections. +- Roams between networks (laptops, mobile hotspots, rotating cloud IPs). +- Would otherwise need a reverse proxy, a dynamic DNS entry, or a self-signed TLS certificate just to be reachable. + +Pick **Distributed API Proxy** (documented in [Multi-Node Management](/features/multi-node)) when the remote host already has a stable, reachable URL, for example a VPS with a public IP or a LAN server on a home network. Both modes are supported side-by-side, one per node. + +## How it works + +The agent runs inside a second container on the remote host, using the same `saelix/sencho:latest` image. Setting `SENCHO_MODE=pilot` plus a primary URL and a one-time enrollment token puts the container into agent mode. On boot it dials the primary at `wss:///api/pilot/tunnel` and holds that connection open. For every tunneled request the primary demultiplexes frames to an internal loopback server, which re-issues the request locally on the agent host against its Docker socket. License tier, role checks, and all other authorization continue to flow from the primary, exactly like proxy mode. + +Only outbound HTTPS from the remote to the primary is required. Nothing else is exposed. + +## Enrollment walkthrough + +### 1. Add the node on the primary + +On your primary instance open **Settings → Nodes** and click **Add Node**. + +- **Type:** Remote +- **Mode:** Pilot Agent (the default for remote nodes) +- **Name:** any label, for example `homelab-nuc` +- **Compose Directory:** the folder on the remote host where stack folders will live + +Click **Add Node**. The form is replaced by an enrollment dialog with a one-line `docker run` command. + + + Pilot Agent enrollment dialog with docker run command + + +### 2. Run the command on the remote host + +Copy the command and paste it on the remote host. It looks like: + +```bash +docker run -d --restart=unless-stopped --name sencho-agent \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v sencho-agent-data:/app/data \ + -v /opt/docker/sencho:/app/compose \ + -e SENCHO_MODE=pilot \ + -e SENCHO_PRIMARY_URL=https://sencho.example.com \ + -e SENCHO_ENROLL_TOKEN= \ + saelix/sencho:latest +``` + +The enrollment token is single-use and expires after 15 minutes. On first connect the agent exchanges it for a long-lived tunnel credential, persisted inside the container volume at `/app/data/pilot.jwt`. Subsequent restarts reconnect automatically without needing a new token. + +### 3. Confirm the node is online + +The node flips to **Online** in the primary within a few seconds of the agent container starting. The **Endpoint** column shows `tunnel` alongside the time the primary last saw a frame from the agent. + +Open the node in the sidebar switcher and use it exactly like any other node: deploy stacks, tail logs, open container terminals, watch host stats. + +## Day-to-day operation + +Once enrolled, a Pilot Agent node is indistinguishable from a proxy-mode node in the UI. The same switcher, the same stacks page, the same editor, the same host console, the same dashboard. + +The agent reconnects automatically if the tunnel drops (transient network blip, primary restart, remote host reboot). It applies an exponential backoff between retries starting at 1 second and capping at 60 seconds, so reconnect traffic stays bounded even during long outages. + +## Regenerating enrollment + +If the agent container is destroyed before its first successful connect, or the enrollment token expires before you paste it on the remote, open the node's edit dialog on the primary and click **Regenerate enrollment token**. A fresh 15-minute token is issued and the previous tunnel (if any) is disconnected so the new agent replaces it cleanly. + +## Troubleshooting + + + + Check the agent container's logs for the first `[Pilot]` line. It prints the primary URL it is dialing and the reason for any failed connect. Common causes: the primary URL is wrong or unreachable from the remote host, the enrollment token expired, or the primary is behind a reverse proxy that strips WebSocket upgrades. + + + + Usually caused by an HTTP proxy or load balancer in front of the primary with a short idle timeout. The tunnel sends a ping every 30 seconds, so raise the proxy's WebSocket idle timeout above that (90 seconds is a safe floor). If you terminate TLS on the proxy, make sure WebSocket upgrade passthrough is enabled. + + + + Open **Settings → Nodes** on the primary, click the pencil icon on the pending node, then **Regenerate enrollment token**. The dialog shows a fresh command with a new token. + + + + Stop and remove the agent container on the wrong host (`docker stop sencho-agent && docker rm sencho-agent`), then regenerate the enrollment token on the primary and run the command on the correct host. + + + + The agent's persisted tunnel credential is signed with the primary's JWT secret. If that secret is rotated or the primary is rebuilt from scratch, existing tunnels stop verifying. Regenerate enrollment for the affected node and restart the agent container so it consumes the fresh token. + + diff --git a/docs/images/pilot-agent/enrollment-dialog.png b/docs/images/pilot-agent/enrollment-dialog.png new file mode 100644 index 0000000000000000000000000000000000000000..9891d2dc4343d3005b12e1b4144b3e2dd457ce15 GIT binary patch literal 50486 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
)1_tCQOOFWYq85mT5oymbhYU4JaCdTsc zS)bAf2wB@BPrW)=HOJWc{vx9ZIc|rpzm8S;vJyK_)KZT2<c#{mmZEoXA8Ac-;Tc)=W8hqSa>dwz;mvUS(#d4| zH=6*HYZx=rbK@p zW-d02R3`^wKEdYyR2JS~psfOdAkRQDhCZWwUb!emEi^-mRI6ABzst^zVe4zdZc8|M zpxO9*#*eH6n78DqQ0a}(4H8wEw+CLBR{PZO#aHb8^&k$%#e?GrHQK`Agt zwi#ioH(H$)Ud47pSerqX79O(TtyRB!b5=f;&B;!^5K1LQ^4Ul>$9}*X1IsTlDi~db zYKhm~d!Q-A06Pf7)PO6x*-K^MmB}I(boV4Ch7VZo9VQ`ofHnPZlI0WwE#6XQY={K; zr=8n2abhgh_-$0M0L|?ivrn87gW@tHDD*N7#!mK{$MmNGG6SuEippP2v~4%L(&>rW z7ZCSSVVAXkuXlrV64mdIorNkz%$HYUovnij^_{i16D0pY1=3O9@h3&wNS94Ndzi8@ zwJ_X@?Lx2l{Ou`ker8>?_!-#K#a0kZK+ggo9Y8(c0u5* zQ=RuxiJj&Ad&N=77Yp_u+<>2%s9Lh0lOveBIMb6@X#K*u0 z0b<@A5U?_SvhW^`>1O86%vJ9Te>M+1S2T90KL;Xgo)JO5FOd5xL6qC%6i`_U6c(8$ z7T%RfQ|e2R@1Y4hh5iLXK@LDhBbdu}8H}?kdu7FKVqhP@Rw`n8s)5yRqR1()%l8l& zOj^YwJGTKT28f#aHY3p0E}5-H87S_ zle%iz8BQI(fdP(@4}pKf;W^P)jq9fRa1VPn_ca-4?tzY>o!kPB<~K}fI{1JW!Sp(q zRwy%vuq|jX`T$@8cNrMBgK-|tv^>Y1<1Lij)|@RRec>awip$*w<}A`;i$#B+PwNK7 z*N@s;|BHGG#infDO*6;p*{);T)8KC>ghmJ-YZLevQt&O?FZstljFy5eUt0(^ z>sPAweQsv?LD;!}&RZ4?*`k?hEoF&ZzQJOqC4WGOt>RKDqFfxYYd=`5(jq0YbX5)d z1vWJl?$%K7O=*QMPlcMiqDx-tZ6`=T zy^PvP(?14dMZW&7z~kW`8E4S3RiD&ZDvd_Dn84AxVex9HrxcN?hx@EBXun1~BC-st z{(E=0jp>v`Rsll?AJ4tOJI0L)B4N0`v`TyT&A1|@8jQ6eHZFi^9L%Dn1NeYz(IaU# zaCigjIs|$k@(TA*|4nE`IM0=G{I_{7Dio*J&}8S{H|R*v*O(ONSCBEZE1)KMyv$; znaOocKN6OD&Tx`FA3$({o}|bs9o4$hh7cF9bu5}S{_t9H8{u%yPQ+oUszpQN@y)v} z#UI;y1oJAV)xB63MISe$SdK5je|Tb~MrhPFo3%!H6-<-%ckikv@0iBylody4w5^oN z@?KtA6GS;zxO0TP{f00_poBofeJpt=7Txs9A#x5;cU%mnYq60fEBuG@vdp=r?-LK9 zY;YD)CyiKMWHv4v$rOaHKDywuk&rEfWoIB^k|SUf9r99E@AFh1vYDnV;puD<4o;p@ zJ-lrLPy69^;{Cucyf;n!fAB|4RemhbT&D8JC}(n-H=yRp++G7U3iBWe&8$=U0g|rf zHYHhJql>++u>_$yEBz-Q?8Jsl{}22Ja8FJL}6Ba`Yh z@n?<`n2O&8j*`AvH?Tk6Hp*}QEtiH0@yxnb7}cxn1u`U^;on!=W!a@KD0F&PaZL)t zSK#HUmTZQ0rIgwsC7p1_%W^BpRZEtbM%8Eo^V*ju+kdu(gEO|vK;O`o=U;5nFR zW&b!b$sk^ZjuFNWVCIrDRv31kGqe%!$EhZ6d&G<<)&9L_&q9@dsxTHQ#?d;;MRGd7 z;-v2|1<@VU|FK!wXkR?at8HH{cutq}3gUJ_zSG1NXTvRt+YRz8eW9xEN@V%A zg5D_7-!zJc^MpUSVOy05$vFsTZGQy^4Mzix!nTX$XAYS_qk361e})^ThFcx{DF3 zbBWlHyl|fA#(6Fd8Kg)LQdn$NoTXSf*6WInZ`0cOpNCTq<7` z@Y2z>hulYsbCGaW@-s8G425bGc&f z@5}*`6I>VrTW6bS2d(Pu2MmiyEtyiGk1>`{W%k{^87J4=BUTL|Z5uj6_Fbd3ZItfQ z4hoIE_AL)9>nf|mcU$T$957uRW^;QT1%B#>ScOe|C4u1QUd>5FV%MNpW7~I`T~6VrI_glmyEoR0yUxH5D6pHJ;4$#4HgTsR-Fg1gzjXg^By>HDuPw-OCex z0j|*g;~8%g`fGu)gcMW3vkC{xsVDhHGHXmR#%ipEkNtT0gq^xpvne=Nwew+F_~y%| z8)LePWX$o7w5)t3C|_POhfvMfc4k~{z}yaR3j?H2v||!;>DPCk;NvI!H%=^^-W+`S zn(O478h&9OUBs#!OL!dfupjs0^iNJ^MvnaQW@8I#3th#Zc=L?9g><;c|K2F^iK?M% zN*w+TGD}4d9A%^w*mG+TVBq9=-l1zOGA{iRIm|}_G=%C1mDa%05R;xcKS+Jcl}9P`|KV56ldu>RbHsftt?)WsGDpDEh^%ScGUcu6ZJzKhJc# zFT0kHuXV#Km}#zIYNf~8_M(5_JU>ymyrVp#sh@5NMqOcxKb(P$BDk5QoJduH#kpF0 zjQho4*lQ;pf^5QdP*PBpabKW3RO=hg%J`Z6wS2dznGD?|y{-b#-G5y6VIS>W*{>C#q{U077=d2?$M)JdvC5q^UH$a!=aa|? zRJ}vZ#yk9yR6&lSJ-rcwfoWX|2vJQWQ_+a?u~P0GTR}RUa~A7x-8?HEo`71-beUg! zf37Z6nmQRxxvJuthe6Lf+-jElC+F@HmZirUi-48K*dPN#>`~~I+qR9@69WTjode$0 zVLF0j&sm_k&+%>`$KtQq^j3>zy&|^6f2nBo7c6@4^W25lmZ}pO#<2MGaEFlLzK@9$ z#b_KJW+9_r)MSl>S@~NxVs=YM#9CQ8lB4QL>E)E)bgb6a_7TQM%Gl0EmwCL zr)|kP8vL@3mti8zMGWUj$?@mFm=}|yXjO$&e?PoiZ+v(j2oLT0U_5QNF|`_i=UlDt zm0#tnP-eCE&p;9n<&f)aF0%hu;b{2H1W|aj07&gn5Q9ioSO$}zTGX{-NOutQS=K@K zh{?H6nvkH}?1l%?4t{QkryhF{DqiT_EAnZ!!5+HS*$U@5Xcj5|&EYa$s>!Z=s(neq zyF^IIzip2^CZrgq8E;@Cd%c7uGA?5^Z+)mIvBPNPUpi0z*!;TD05bcD0kUSFj!omS z638ln-OaGM-+ii5Tmf5r-x)IWMB|W(jAt&p=)I+ZuEI3a{A&dWLu#~Cpqd1+2lnAo zxR1ied|d2^IDxriX`Zbpl1O~!Njf;>HlIg0U&21Z&1Ot*w_-vD$~med!V?#xAyc<& z&c!H6HBC5`hAQn^3jcO8T^;w`zwxBv6LWs2kY>tB2=KF&w31F$MdgoVpVz)eNg;jl z4{rl+8Om>%__D%=DgyTR=k9>&(9eUZ2HmQbhV7{ErXkX8=)0^}0)(rUVgj4M$E?ls zd!Duw2P<)EkpPkP*XPb@>@peNFzb+C$~q>|s*%@0-AoZOzR_Q(k}nQjp<4FqcxZRlU6#@i%9Ot{rdW zJE8kv^H8Vw#b}}h#rufCk6W;$lTuH9{9Q-jvhS#!{rBc4X!ebhCpO8J8{l-evHx}H zSTtNqRWYqnMscE3+Y?O-J^4~7CD^H}(1(=y`soP^iQY)mj7@8y{=XID8I`~c-=ikW z0e4hQLfp>z-Wsm6qd-THChTs7Q4cn*z9!4pw`W|we#ZSKs6L56cV&nu{{iMHlW1`jy7 zgiyi^)z>=FvgFaGAE<>xtLBKS7zP)k=6@ZjVeZfC?a~R_r58mg=IO7#qbRG=e8@1@ z%-PL@^>ac!^?Qn%*~JX)V$!Eu^bBWCxC8QUp1o6{QCos8ZIY#D_&sZu<{gQY=(HG|KfiXBP_%$E7%=6?*zVis$lQTLB zcI;XApsVBCRw3r3xiGPA9%)plzh1w3%A?8Es{8zYqv7TDr-L4IJIyIx< zN}0!hN+|S=gBiU(ldKd;8$`j|4FYc5L^&Ccsp4A?e&n<<#i@w={P>p@O-wqtNfd2g zDAby6kE+jAvAj{wjh{z8c{SF;2P2`hO@%vL*ZPJf+6rW%l419}D|5^qYjH>`T$dzDhLX`V-Ro?!039N`}h4W!1ivRG+Ek)&3oIpN@gD)2sT?d%afvSYEyObfk)_cv?vOLRmJ> zg66A5#i?_+6z`=L>9vtm%T30QyO?iAr=D3a)TI1)y5}S5?grhmCZo72%&+Q=c(dl{ zSC&vW%g$5%N;20`E83=77MqyPjx9@D`mcTRv^>5D_eg6hcFQz!bX-oSII`YU@~>R& z(0hlfZL9`IUVMaB^4sVnHw3)XiE~Zlfy-FXWbXW8t7zUUw_HXzJBII&DZ3_kOjfBAnm3U6}&G80L}|L<}uj6l&pCs!Vb{f;0*Xa~%-SaC$2asG1~ zk)>xp*yvW|w*yWdA_$3C;(=JW1}L0>i7NXZtWiY}WkA4d48UG|fV$at$T((=*klK? zQl%Wk4n+Cb`7PssW$#q4at6fmMzHyhZ-MaYx!CP@)qxQUC$I(?AR`w5^F*s&?g6ax ztOijf5OorKWMU?#zY1Z1$_lpIw=@0dy97?c$Q9Va^*X4T6Wkc5z)k~t#`oJzqP}VYuvqWDTtk(fqC>^?ZMyxJR~(1D2A*!%?Za;V;l$JAqi& z!=U(IT;~`1fNFm<1;8UzmgR2}aY2gE*1sZ{&B(5#APxjP|2!(CBtmYnVu428Z&YNw z&}YAyNA6$mAb3Zz)7bgw_--*4thuzUm$?V^;E%hy^u?;a=eua+yZWU=NdvSOz}R zajaRNkFUXhj}==8vBfi+*UdB($X?5OZ&Z_ZHBB0Tba)D2PNombe_4!g(Xe9vWdA~XWY&KND3Pv%o8gdN;aKXU;=Hi>K!h`dXf@~UD0Zj zOHEPG2+r6MLft{`WjS^Sv3L$muY6I_Y*3gF1bJ*CXt+*M~>| zkO$DXI(eNb2+mU-+JgSoCO8sAbp_@Knf#BXFAFA2%~=|93ltZGsBRu<$H=?FDyNF0 zM5%5F&{-1nnJNx>F0CrrK3h>Hf1xMZdfL$W|3D6x?A)D&zARFr3JBRgsA1_dHLm8l z>bAGk-8j-`fu%*lh^4+@ItVcuq@i`9_Jz#3;OS@vXFEc3jQy|*CASShWmp73zt_h2 zF7)lWWP?e;%lBBI`bD$_!`5vu?Dw{~^+)hk1}c=(y5pJ9yFyk=0t3~s*a1V7Ln8|Zl~es6cUtw8b)K=m zJ%cpg3CZb!5TY$s%Oi;Vb|AK!ffQzjBanBp4urVdX=USh%F?3RcTdjR1FEky*m6$O zt+Q(=E^TQbtHQETDs^i>_GIODFSASG~r4hGO?Wez6n&n90p+vk{A`2TUCXv`;4=t%T`F45kb(G z0~VbBZ&fpGoInp;&$pw&SRUf>RBSVe4Wu;d_5M5mJ$z|BOEydB9uf{l6Bt{b5vQj# zr@M^KK|H-|rOb)(KqY+3fc}6J)IbHd*v~ zaeIh1eimTSzIIz>I$8+|M2|p3y$%}cU$s{$C62QWFA&6$aP|;BnE&u~Cb|byr;C07 z{~B4pVbLr6yp!H6F5G@P9lZ;d!v>JF--@pZV1NB^iMf<|=n?dEsMPziJ)# zjW2e+>ELZ9`#@0^=~)xPTJwh8Xje1&^zyi283)38fUnrS;xRSvd3xYB+E#nV_wX$7XbGG^pH6KgR zWD#paSJ><&i>i^&`F@jpGBr3-TS%8e96?ksZZwbiVMrb&;cLO*C#*|*M0?Q` z-n4c)pEtU2vetF`KF51+%DaW(#@-lAcbYCBlBS5w;Y?k71BZ#l4Ho?zEKq&zVmJUrMNzy-iOhEio(~UI5XM)Svc%s0{!*L0#_fF$i~)Av<$4{qO%u#hG}6nd%Mb zwV`@WdQEoPiz1>$+IyWK8gQ2!Zpww)zweRMU;KZyfTEmn_=sr}NkHTfG(P1fe5O>B z5>N7&V8=cne-e*gnZ$G(6CcXIhEw8V4DEQ`zy1*$415r3DDpL?rgIE0aBS5eC?vZd2JtS;eJ`@z>4j@} zgWZy-ep=|9`Y1xOj}`Mmq-T`pQnuo0}inc~=W%Qfz$qvxvP7O-7^*bly?Se$8QVY7BXI7bK$amAvLfs{m z-1h{YS@d3)GW}_3{bmG><`nP4gE4sRKuJ6!M@f@FIU-GtMJvYluh0%vbSi?V|wgDrR zwOeai;(XcAi-pj{sV6b7vd&V*&|1`8UGGrH{>w0Au$6hL+lZE;;PrF(2`!Rsx-Ciu zwDB7DV@4>V*pBKMU~^UJf6~IC#K5kLI+mT39QMB* zA~s%X6td{jfx*jZ-|oSck@cU>_~7bi{MT)pI1A}Qp+=egcVm#zIs|I7M7W#4bN-i>` zb~q~BLF#y~^m$+7YrkH+@a+bfM&2H`UAcFqmL0%GHFe0g^z0O{n6h53@(F&w{Oq3B zH;NAZTvM4!s~b58q^cTzCO=e7Pm;?~Y2tWhhH37~%l0dtm#$57kA+-9_QTG7kb|1C zG{U9ujXclCGfz<J2?5ynl4H z^tA#Nyk%;IzD|L_v3?X9Bf+aWr~$d1_V)|>seZ^q{JAIWA5U7Wd27i|>p%xV#68ay(T|p71A6DJW;iM zuxsR%hzz;xem);E9uTP)b>O4e_B9GM`>Q0Z;qjohQiY%^LWE9%W10}+OUjg3vK}N8 z1;0#kG3OL{6NAyVhW4V)4-pa;1eDQG+!DDhHs!%C*tCPo7xtpx>O(Ccb2)UNfz(%x zylKh!LxIdUNG-y-`2HkCFT*nn)-j73*D{_^6W=o4?d!oAtF)OoSL_gLOCAcvTz%{M zKJ53Z3+s-|#q4O4;rIxQ>vKQa@Sg&H#?1dD+o2%^H=ULFe|3IH>rlV$k+A|{hfIsv zZ2n@(;#qj|dT}J7sCxsvk~>~lVd%rRujbJ4awU^RZ50pZ!ts{}Mz5ReE4kGIz#`gs zCgwTB>EV9m&%lKu%iJ|stCVcIY^&2!l@#m@sUKn}Bf0{pQ=0PDW0-gMa}P`6zOCZq zI}k#$BJ*sLQeO$t$YSl2_87XG_vwv=>nP0gwQJDW>W^C4!w;Oyu$j3Ywl!KhW*`*9 z81=&o{{^#vF0D-OtB&5>6GyuCX^1Re-xI*X;QtFg_;&AlFzf3^3F=&5=!rm%l#lW= z&1Y2gzU>ZR@b*7qUVuKG_t zNK^+nFs<8nmq52A>Lu&Fvvx~Ll~bW9m_W76e=ThlK3YA=W2o_tz5vWrke$R&mpyR{ zL0V*H=UbtYpMsCp%4Cq{2nz5cqyKV<$0orTi{3>#6#Wj;+L+v?z`twK4tq^bbXw|ATbofNS9;RLY>TOsj{epftri za`^(f-WvXMNnl+n9T9}E(5f%}-b#G6C0GS#{gIF9&QeG$HK=YL7~-N%$9S2%`O7QK z4CCj~j9AFrR&z&*|7$1>X&XGT`BUt*T1$X#EfNB$c|jUzBfz zNVeJA971t|a>rTs7;RPg@0LQ^MB<0%7Ut8C#)@w(GPCH1>;7|V9{wT^F>Hb^`})6L z84O$=;e9@_7Qfe>^2RaMCwc+CnqQF-C>m74Ov5j59lneyYoC4n;*x~z|ANa&J>-|{ z+pPt(2f;AJ=Hi#%d+hEHf}V*z?yLT*8hsE>XDdaWkfYU2Mpaz!7TT+qo%_}KWfJ>m zB=r;8e$)5o3bk<38wppAQBd-kgQilK+keuCK2_XpU@En$8{`F#L%cbPvP ztmX`w*~R0pY+HbSkeTA z7C1ct+;$V*%hFcQ_sjW*Yuvy7QQ8c;Lg9|d83R*nxMSso?E_)B(g%FM=RkBQ$Z)A- zg}tr2OFi=8q-3`*+-AZ@orKF_0yIZ<@_WjL#Yi6x)X8sbdtm{4Z z?6dd#KJOE70d06i@xOFtrsRam0r2a2iz=1P#zDJWFggAPCGdA%KdeZa?jqC&rqe$ z)}+bHkotNZRB-UGOWJA*G8}VAi#79GgC2r8D6lLMiSf#cxD~gbt0xre+ndLaLYT;Q zw_^!rJ_zC;Tyij{`YS&v)!sY;$ti>Uzg1yXR?ku~H+g=g+!4-_+Efh{qW+C*QrP#o zD}~)kfjnj6gx5DRM3Q*5XJQ477o%>jQk_bLy?jzP=a7T41H~53s-FQmRPxzt0FA;W ztJkU|yqPN!ejjqy%VjulZWcpxDRrAk)G-c>l~U-5>l zN%C2LbXdswZMntsh1oO8M`atVa6`d6)>xgm6YmGCTy{GZ2ar3Y@H8#zMqyOks8brG zj$31DTyU}4`JT+(n0XHic{eL=7C!?Av8WHZnbP){EbU&Yj(6(KhpotuK=S=23XZnB z6t^pD!y`o*44t#W5<=wpk|tik(&jBgg46p&Q7RT>cUuVV|1gpF;1R=XfAlbBA}|#X zC&#OsWcBr%jQ0w?Ze6|l*#fz?=KAc{(xV+t$alRHTSko^Rj zYyjShxXOD3SX_a8f!F^B3`=rD=w1u49#aW~3T*j+LTr6ORy5HGAgEw>;t8<>M)zyA zI&Ag~yZ3-c{@e)|SaU|1gk+}BA^>2sJ5Y)$SWEXnuGYmsxS@w3&z?}OM>|p0YLusf z)SxocctyOG*m{wB4P3rYi|n3NS5yhMT0n@(lhWMXUwkWGP<%n9_ogn#-B_Cc6f^H8 zq4C}tT+0xk+-332b08T5=$`SL!qyuoKM{bu#Hwb5ma*lSkdx8`QT#k00tZ?JvMGm% z>kgHsCB0BObA$Y|W$$!=Y=X?5uqn)Rd72L^CMuK_fNz%p6xydB1vaakpn-R;U#HVh zxOac$XL>>=vO=WN@3S8IY@4}2*L*6(O~ilk8)YpQ*iT%pJU=-_&0hjN!3osUc(Ry1 z)0t5G@N%*+kRH9DO3BcOq40rrbF5zaB@VCN8lWoB5Yye$52p-eD z2;n$-P!7v|NDhh(e{d7{96WTRD)V3e{Wi!)+pC-fN|uDy@v3|LO!GM)+;0P* zu`Cc41MR(yLVUXENF-XiF^y0l_PH(yt@m8sa0X!30R1~UHZ%^Zo5!Ic8{YSw zzIYb3U6H|&UE=lfG02tyyEle->B7-g3oK1(%{Typl}qd9LXy;q!U-AvTCD?+d|b0R zt&DQTql>(IfeH5hsENU<5U~w2muvwT6&ynj=uY>X%80K8ZmjtN2~qcn82cpM|TK1MYdh<1n^|Gbhsm2VU<&}Cu!0-AgGLC zo)5xOE*ExcX=1ar0I7Tq_o4=KtUQ7{&?d9LzQ<=k5GJydWnhv)o9|Cc|L)p#4yFQY zn(xb$G`roQDP=08+Jm%Snn>J$6Z3F_+ZKZQJU^T!o0wsR#>Ads4f&#!8!3=jk z^!vRXW294xCZ(y%^Ar|8A7eZvj%N;t&}w~|hwu))o%t9oVl~*MbG$B#b+*dYXw42a z2j^NUq#7cd`HF+CXRfr}S}{vmCwFFAuh<^`9)41StFg*(^3N^X@gl5k9j#Y|jo+4V&m{Q{xJC+t!u9xVWLLsrD1E_Vu z`+$O4+8Q?RY3}ygvt`j+S#=Uo5u7B+F7yXKH|y%(6HRF{VTZwx*lwQ7?%#D~yZwG| zaGLymPw7js8B7z(z9SYhV;whOCaMBzOYYwv151}+a>Z=O`z;f|NG?!=IQbc_05iwX zoLZkbrl>wO>{?EzVCN+`eBwHwJ(&CI>-NFZz6P52@s&8p&}fO@cC!~uP_#up=2Q%T zu#T!{ms6%F6!O8LNCEk({@^TDGv48&FlP%%2T{_HA@Sg@d zXFh|?HGUS}&h`~0rD_mP?wBZ{jm|&w%i6*r$lmFCud|Buf_)lDOpwcu(B%^7Q>xQSjYJm1+}tHHTG|L39j( zvkEq|IAdA-+zDVRuv6sUl~TJSEFDO@A%-Lq;!@wx;>grhAt{`_;L$fj$#@R3Kv=1~ z@iden$@Tj#=M)Gzo!a-!&rkQMVh5V`3N0EBlH!EGWU%|>4<`kcou<*Xx(jl*c<(;dm#zt)5VrCte^SR zc_OE=s>}1*1-R*;EP2(#dL z?z{TAJnqG_Sb1I1RoT+~YvGyiiePn3{W%CPxy}5N>-a`XX@lK9E^J)6$jYyr(f8>v zFFA*s>_){raCMhp0d=qtT{_!+y>zfWuE_dvQW6mvce63)hhoQNbM z)@UT`Quf5y{X8=Q(Vp(0Brf-MxUL?Oju-?Xd*eFYRpHOGT=!;0#=S$e;)_F@ch~PM zTS#HTQ5hC3UO^;1a|C=(|1ofTZQ!r`S_z`_2ovC3$JyyLEMCapOVU zwju?TD=rytX-o*V2tvwM`_PnhC$!xT4I=F^85&Bb9JY-__RFW~*r;)q>fksKkrf}P z=RY&0q_~7q4}4_YAD1tSioo{{NasDxs`*HOmt)OyDZZB-Dex(V1;$adfgqgeC-78= zbJDM2m{|NZMW)ZSZmowM&VyXbg3X=X}s|LL8#FYtzdO^5EIP7xN^frU#$>dLg!~^ z@-mTh$pO*~{@scCW@N{gD++irO}i76N&Sw`z;F|_PQyHod_N~b#y7WSoYG-Tq$t&r z(-#kdn@fzkJj$$mz$nRUh-q{2b3y$RxnjrP5Rvn$ca?rw48+}CYrx30m>eJSf`=sZ z&&)oYzK?B_{yUKu6U_TKJaL;#WF^Bp^m6P<2Faep198^pdI~?;@bYNfshEdLOH41R zHI%vQ%az}o#j%+h}xV}J|TxB1CZ7ut9>>#;l?q(Kki4Ygso+(OUtMim)bk;m%Fa&;+ z!7gLh;9RbHog;`vp=Dl$QA<8QpVEY~MLmwY1#lE(mqNOWUsV@M2R6t=lM%-mrw)G{ zL>t2hj7uMpmHa`dX~b3qSIKI8dCQZo*c#(m6K1sm1EoZKu~#VG#5#~_Mm<35nSCtk z-+gza#49Cslbptxb)-}dfoCT+CF;p!HN0Fn7nM85U~P*GN7b zwTA(1qOf^Nj2cq&pY)WEJo3b4^PX{->zb&2bUxc6C5rszZ@H+8Kk>L{_i`ZUpLBvT z7)Gd2*|x9Z(irtXDYlseYfT6=VJ1KXJ&>W@Z;G2+UNGNy!zCNYEIBnw!`ZNO5s=)g z-#KM2{~ICwWovtGu?BQ7gV#T78&0AFyDZq@5y!=+($Be)6@1yVX;xN%E=Fwipkrc7t0T$-9ibM)#mS|LKd5#gSEX$jm}3oVGyYw}E6 zdl5oGISrqOj5v+)w$f4jmw9@MXNK;1N;Ap0jzWE$xMba?LzYzilu2~rXsVQj*5kJd zclo6_4jS6L;qM~!O-!Gkfk#v*dejHq+wnJ(EyIWUlr{tT2L`C2%-M=@<%{AY`w-F> zr2|JzlnP;?3cG4Tj9R5E_!S<7tUB`in96Yp#7{L0Xz}aw^zh3{Z-i1lV{Am*t{&Ly z`D$O$+>A~eQ(=$$qf`pk4!0zHxk^yCi7tJ+l3Pb_7x8I4YcNsD9D)W>mM#&REe^y; zAmEkLU_vS{=u7KMEfkdY$y6iU+E~Q1v6e3p!bg7xTb_CHp=gf}9<52?LIz{{Fw4dI z2HHPijGYA^Hhg8CJL4oaQxdrggSkmBnSvb|C0?^lqz+~-BtFD{m|D9`4JhW2NK zfZX*(c9u5v?jqbyLm`T$W=I>E#=q9^pcwl!=~FqgV0$-G{jMM>d?hWWBMfnYVBzci z2w#5@VU3xLGZunQ)pIy6difTAEYl);L=r{G_ZL_`-|ez#Ij8)HyI3#_2sIhusO}62 zE%6$!7lJ7-DX_xU7ctc3KMYYOd-1A#YMT|NEJlN&Wwv>10-W+ zZGbQ&Mq;hM2Adu`PVfKcwc1Q_SnyQW=V%C0Pn#>;pTF;`Z%jU4->oaFl?V5!2d6O> z)H1rEielz=Kx5sq-Z|x8|LZ4F{AFGmEUCq<6r zJ1#-kHJtr|&Q4l-eDcnTv%~MbQX_w>Xuh8X@>CyshqD;T=@fL1bGPe8X2~Gv)B~Bl zRGR_Jsn3~JT_UC|B<4f=&?1${9@yK_kmqnW1dOy6^CVkhgmffrx zUz<<(BLiOArQONzt!lAenj`Zw-5R-ngwhr6|I#xgX{Fe)7Ah&DhfK>tFVp=-%{=qSRvDUp4xeM9xG99v(cS&quXpt<+K|Vaj>0 zP1RYSUgHaZK+nk|R;Dvcj`4B3 zIOj5D9Xv_MtyIqCxd;xe3?@O) z(33sut+tV8&rN2=eZuiU$e{6COY3JW0Ort7at;lZc+26LIq20t6tm0hCf`7#?Gz5R zg$@c9L<5z9Fz@a&?ix#i@4Kyiy;otgd)L!-QP*DttAzu$kylObUQX<)KB zD)N2I2>F)$*Czms`s-C@=32CNztS8J!LmvA2@B~F<1J>M9;_K>GmV9uTTNH0=q%#8 z^V&`Nnczq>Qk$;DKJAJN6nRP?kE&1USWka0On$>L{VYa`Bd-QgpJ`OAAM%+~%DL5) zHKycu=6yUW@m>ab3)gs|Lc!In#i4<1KE}qHbi-P{R>1u#Z)3z6f%lJjSI|>=X{cjG zW6lzZ8AjWV&({fZsvx|o zzI-mA!jgVwn+l`}qv7a`Aa9GEOhVJb81XdAHYn!b6rayam+ETqGXQ2|GU6|GO*Tde z+*qY^VhgR)V>_|^7Yw#52=YXkHmm$(*XK0cT2&HUgo8~(v6&*7{Wx1hsxfDOijni2MEFm?5z>DthacD$@1$&)Om!lhyBs3Vza#w52 z8DpVhDJw17tAx2rf#^8I%`GL5xXpD8U%zkkbttQZh#r28<6pbK{tU;SQ$2vY-V@>a zl#STcAd5x77XrcIjlQU;qVGh8Ww`3ajKy4S|8xTw-k-`RtTM?zWn<3$iS+1oSZ84l zAZSdn`+Fh|5YsCXvxNVofii`eud@74(@w!3u$fR>PC#b;Kf*%ML3*W<*0RVLg$zi$ zTv};fLIEC`Fi?{hs7xJL?sG#eAAwKP+@67VAc1W!Ku7TfMgd4k1@*#&Mqz4sZr9A} zg?a%0(@>{6dl#5B+UmixTQqs|8-SrjUI+dI;42{3^Z`h&D3ifsPg>qsH<&EpW*xh2 zw;24}Jy5e2sYv23?)-y>5CGS8$w_*U1z`-eQ10HFx{ZWBX+}skyR1WZKuG25lTG*31&Dm6w4*! zRwW>j^i(55uLh>^?#^-_?+D<DS6u>6B!5YYQi=cRR-mb7SM)VhHI8_|{nltYTR0TufLelLZxK`S^@4MF6legs#5 ztd7U!6ciD@QvNnfakF&nIb%?wB%G2f#}M`n1d(457u$eu>v0Xm>w6=%+IJX}pOkQC zAjj1bn0Q9`%mKXXE*UuAk8o~;K@Cj}J6ISyo#j-_Zdw6)0?WoS#af!w=B zY1u7+waSsyS{BzPAbXevXSsK4);^gOy?X-Yu6Z0$0~`pa8+=Xxbh`zRvh%dMQSMAb zzuhili{c1%i473>i&Kixd#VlvGkGiF^GNyN9RLI{8T>jY2b4VMD!K}M8d8l5Z0-!| za(~};q6oNyZ0R1+&3M14Pm%{A3DB6(ICbgc&Jb)zPmnJfarWU2o2}?lAFg!H#}yl^ z*np|fNdCKmZiw+M&Nkn%%Lo$7;GaLxRNCG5$_3D;muA1z?4q~ehJWEhDl?i7r^uOUxSv^H+@CDN+_ZS#sEo=2XV>In-l_|!g(1D zX1SRSIvL+wzXMxSKnujT@3`_hc3WuznX_tzhNTAjwT}2x?a@IS9b>_UfbS|oZ>Rq) zy!iRY5?#2wOrM<>_a0cL95=$>DC=}&vnW|MYv*TyNLgSA?s8U|bO|8(gC{_z$lheusu!hv|ZE^I7xF{G8w! zAax(g)@q{d8YKM8wTBy!s8*1N|9jeOHP>CxrVwX!W$0;at?PkvBdgz6CXQ^v{*w;% zPz$)fUbG^_U;j}(X)B|A1da>|m$nRcM~*hu{STqT0w>-mIcVMREdZsT6;;_8;#i5p zY}m~T<)#Z!H~8Lq;?ICHaFLZ05)aLT1OvxSU{%r|1oh}eaxbd=SKaFjPO41{`w*(m zlq0abY?M&HJIzu5E(&|aqT^xG9QNuwGee1yCV)=6zDF5N5?IDy)mC=zmYhLnCUL>o z&ZkjVU?pUgEaB#w`vyRF%@V|=ny?fC_6WAA43m0DmU}4UK^cdZ`V5qg5T1SH;JS88 ziJL7}25d#|t7x(OIY8^wJSjKt49%j5eL=$g0CdRJ#29zE z>TleF!H8wSb3!kLc(<_fq~c7?DXYS+Y(U?yp*acZZBTFd#ja8sP*cJT&WiZR@PDUc zWLZqbe>zU_lkD`nU0|T5EdPu#a_Rf8y}fpYiS)8&F^RI+9cvZbFiQ}#Khn{@jOfRr zulme92#j4ApfinXz2}hjg4%C!>=}_C+1WBpFWNfZ(nxIQXVHRe81}ta)R@j;>JpV==@Kc`u6=|BtK) ztd8Urw}fFGbZN6hLH9uyOrN%b9Tis78bq(|LMXC4a(4BxJMO_sXt%GZ6vSp)a`^F+ zg?Bf^bfRs4dmC*T{}Q&OmKG8pid8b;gP^dHT!I%6(#%yI0cJ;vih%(lLtjQYohRe zAe77mi^=o%9ar#96+1R|)ze(>^j_bDjBqIk5n{jG)ke5pMtXfR)~9B^}q# zf?tElQH>6~?vwK3jlHW(fzW_(kXb@_U6oQO)^ytocOw;-X{zs}E)+*JO=q{hD_C_0 zERvUjg(nEzMqy!hM~vtOqX5#Qk|_;G^EB#7WyJAgFc&kV!JDR?rjghZ}Cp zY=oG*Qr`CDnn{L|Gf|A|R&5)to4{DF=27*FJ9vIJg0!f%QgbYaB(@El|7@E3A_HLP zBz%^+&{Zix_QeckuJ1&={a>&&RD+{{a8-%kn4?TPs`?FgpeBJbT08r8-pP!%T$$YAIGHN@t@~32TE)z^}#$h9hvwF@Z{u=^+~VLCyCYJpab?Y|{jBVNp}$ zMk1~u0Zu1Q(9aM_QoI;6xL~QbpU818m?TBdlYT`RD~C1ZCVZt=E>Pabzt*x@8PHO~ zs1>2Z0M=60s&JwW>dwJv?{3WErRl;G5rO zw!LPMjA8%tGm(_a8_8KR2CetM{dhxrrq89|ebQZkrUWOzt~e=Fz5aSV*PNvzS7p6q z)y3Q4;*%BGaGrQSwT_lXU{me z$*5k_uEZn384qNBI&S3LSv@#7xW7D@skd5@@tl|Lhe`F66!qTy5tM-wzK(vL`cI|o z8#KgwW^%G3@ynMlrxENDT6z#^YHL4NQBhHx2lCI^YTX1Uq^??8TFP%9QCIlrB^AUA zgnPe;ZEut=`IflLl<|{MQ(uXa7CLk3%_L-+#{R!A`2YSj{O?z*&a^IKa%fKR-h%wij3X+fUU1h zEu#slQVC=?*&n_a=1X;ohz(leZt_*Tlo;xLLtc)rE-biG@@Cn8))0_ny% z>1a-WP}+YL7~H+i-JdE3R@Jr!(9CaEyhv%TUYw^ zx1i+Ue^Nhgn#aSnlg>hRSFV2kTN^A1{ zB*1p-IGYuqPY$lr!VMhC9T$nc1lu_L|=W??P?FuM~p{*U%9h4^}+7>V{tUEzF7u%LC9%-1j z3OZ8=xaAX0OeW(wP?~wp;J>+QsF^+GRPpyUAnmJkzdcj0HmpRg8)Vl3^%WF3Pj~<{ zmL;IRQG?DL!FwA_`Yj?3m>AA2fHKsN4O(xf98{o~+buTOfU2KpW2hXIC_ow2{w1mL zm*tP0MyE4i*nv=T`%G0cCC7KE6i1<+=}S>okm~Xi9G`qe`hzFum}B&Q_qFwY1xjZC@`j{dPw#ejD0ZM(qmJymv!pb`RA0r} zT+vW08%#oF`0QD-y1q5*D{B#2iup#fy1^7AG;43|1z<;)v5Z{TS6E@YCFPTLpDX;Q zvico`)9fnnET@ru=Mzh3S7HBZ7J=bKK=DX)e=LGSk*+()3G7Glnp+iUT06MzfgYy_GnNZ9EDid`K%jmk;o7j^a zErQmQJ9W^=&n>KC)LXbASt>Zjp-X^b$RT>2w7-^1V)RZ{mlFS|zdu>XIyq_b??aP_ znzb=Sx;+{)Dv#J8v~m9pE2#SOag97qyK4@bUIKg;!tVJ_yU^!2e_I1A5jq-QpcW$^fdt^R=Bw_6Gg5&_#WSJId~7Mof{QvqWC<;|W!pY1^_BKhzOjV{bPv zKo7VX@E%>#PizRGLn7p6l|rVr_BmtNAxI3ic$w-z*`?`W0~0ucfVo3}|65hCpjyzf zTew}Y^3}U!mSFVgj`T17A(>?_#QX2ktmVgSxrLQ;o?Ixt@ z+a7E~obOPsnR30JA|}_(8%XZWUlL zZaJZ@y9R(6>@CSb7&)UI;Ha5=&u2PB8Nc;|H=YJX&y^tKof7vR7bZf7%0jTh(kr2e z*EIIn5>CuXBrxpK&q4fn^&hc2iCrk174}?EA*Bh`u;z4~Ye$UZ!}O?q_R}3gt}kfc zpnkY&oI;_lr{nIFgZFoP&5=J4vrJr#|GT`C$#yv~(BJ#D_J?>X-NjbtOVW))A_>tD z>g4IBsXx>OMJA60jtw%RT?YM06`nXLxi53n^hkz?NxqE996}Kug?I8v{^4KS#(Jxt z`s2XGI6d5)wkURp-AKczqzfSu_)sB{1Zv7R<~jvStjpdGq>!*ll%055xRU0u`SZip zyB)W1&?zJ#lKXb73f`qJA;DTR=pt=^jd^IM{HBXnL@vX|xpX;R%yZ&&4Z$VGIZn7a z7vGFND*Z6P9*1}B&3AK(7E+}@o7~Cbdn2DY_?m`UGRkmiCX~Yz1O~BlGa?H#8=NAT z_}W(viDYG0sCn^9*%4&lK?467ao)ls<8@{C=+7SVF%aZxku-z>_{dPzkr26am3Gz`QW5a7>*}t5v*kKh%(f@39|& zOS)rv9N&a3Ym^VL9=Tgyzsr2%_SQAvjt4OC)88P>Tfa*i7GJpb+Y%eZbZ?SydUpxU zcOXs{eTN8!GP?APVRGVyOHM@-s=op;m)rgtY_m6TjjRJ|=<5ty_$kS|gdIWg-GiZI z778S4TB*H<4T5xprY`r|xa?Jc)T8(o5RJ7|mO7nb>COS@+W)|;Kb|i0o&~SHi?szNB6Q6UWG5wbeQUR? z0oaoColaf?Zo{hJ+8S{mry;x=rDV2J>Bu5H%KH)SPC~-RNtmsB5V3?~C?3Qy%qXR1 zDEK!FxAP0<8|Udx`0Pop_T-*k$D2f>l$gY?R&#C$x@o$kPBqeLOy;~&$T`(4$%{qV zYI-iS#1uBO-*KGQ*Yv`I^hu7n6u4o&ZM)}sbq04MDC`goF}P2!E7-gta?D5g?Ux&K zG;U!uoF-{t3yL5e+deY+qF08Wx_I>=q|}i6Q<5cfbWE^m;gb$ko;DjZMA;lI))^gWZ}zS7l;4+Y63(sTZPr zpHX}Zb+4jcrklLdFm!BMvW<2CnWU+m3cJdiPv{$h3?X9|FPw+JhMp(xA@7gL{7vgD z->j3_8g-BwF>;cVGZl0UjwL^j=zu>iK4c=-zJ9rE>5WltS;H%G29B3xQj2R1YV(+Q zUQ0L3Em{Naz1gz~xwuA9seP>Ll)0KLRHRxgZ`@81C-R7k$*Zel_gDxqrlFDAX z6dp;)lVI5nf~uDHKTxHOE5h=1tukF#S+8{@+^8{$gD~20V@58Zb=VZsyiV3St4Y!m^V1BP$yH{HKSfpXl`!n*(2$48T>q6Sk#J3`a zE%*6RWV-KKe?-d}y4MTO+b8A>Zmrv$yxAtUQ^g&RiNhx&Jh^?F8iU-JLD0YlOXWtO zP77BlfS9Y@o_flq50Un#at}Amaon(}qq zYaNWU%jJ4Bmyz^6dd!CRClAPN`IuCJ<0!2#IzjGM?i4akg(v>@l~eG1Bz-DN<={}?dG{tGJ}O7=$E)(^u|wJ5+-6ct3>s&$%ud)CsVGc$ zEKIGk&EmJ$kLNKMJT%Uwi8(Ni=vpx!VYQ?8=p`jL-?xxTzWMP7yI_`POqM-qr98TR zJ0Zv1I;hp!;H>4M`wX!(CWoNfhrxcXtoeK(!k^S27=}So;U(_LTzt!(h1rVZt-q^6 z^jSAP&(3N!eF8(S+>bP)yyyY>=&Ywclb(6 ztG)?j@a_>wr=>SP3qEx}#diHwPQ0-;h!nnPRBRR?#^;&PO5cc&>FW_eRB?TtYNX|B zgSK5@^uX_aa{_vmf(kZv_FkS}{32jinEH{o`S_ zQa?NC{mB++ACZxf<@>;0C@f%NmO~}LECt<}Ibi4lRk;jCU{MkE%8x`dGVL~vR_T7U z?WOFG*vBL|CoW&BNQ@xT{TXYWf#Ra{%fcsG7q{}0$M28ev$YiJFrVDEIVhKf8CTB> zG4z&=6LXruj|?uEksOm`RL4j+KBMOf{P@Z9Q;$qDWQEo^C$DlwUylDt$XVZN+Wns^ zA|?lp87z)m7Wa7aBz@Uz&VM@8D|5%3)J!!$P)p9QaQlfyh7YlztNi_qE~46XJi^1Z;o2z0sv4$lw_mG8CCu|&JiD{xZs0DHIddv#J+?U z74UrxVel6NgixQwXn36i=O?Kt3NU^)6rFzkg(fQlRdb1UOUh*ASq~S$b*A-29>G*~Z>F3m z_UJ(~1FZ~?@%xK`L^{psz9GpwFyRweWS4quy$`{p$$DS{cUU9xx3kz1g9sTz716iA^90txC9w2Z zNoO|bk2~Gyv4ZU7Ddx)>{PXDWdjl3Y1@MZ|q?*Y4vH-F~z*Dj`<|r19$7Z`JvAwFE zy!^-e^@rZJum$iPykY<=rasTwn={{00Jt{1M)@$<2E|T*X^K7Qk|J@Ke7pjY@f`=~ z>X8UItpQ?lXc?IG3?|2^tR@O-UI2M8s)kz3#|gB|E^Q4Yijy(S3G~xT_WiwZ@$m4# z;9wt+;9)rhYtk8gdoMLL_5S*JrA19rZtePgHLS`>3~6m-R37W`_!s8mw?9(tzzbwi z|7%P7?>|cU{Qdj4ptsu%0lt}(>dp2@CJ|<%GfF!^9m(3kJ;43X->j=7Ujahp!asM6;9f=M=YgQ*(27@G1jjHw#R*3(kpBkBh*55xDyth|3_^ zO^Gz-bYbN^Aa-%1&r)h5n*`qJpLyS>710$Ho6CDrNz=st=aoUX;QxNT|KC?f?|xo8 z*@3Mi?EQhk(9qD<)>bsBq!gT zz<>0@TebxK$N<2hU^@hBEtphNA^KpUo}p5eFwsg4H-F3|i=EaNWXD13Gofj`&z(cng*cTsf{aBr`4?V#v OFC;}}MaqP}`}_|DgGS*1 literal 0 HcmV?d00001 diff --git a/e2e/nodes.spec.ts b/e2e/nodes.spec.ts index 084e66b8..a6582619 100644 --- a/e2e/nodes.spec.ts +++ b/e2e/nodes.spec.ts @@ -31,6 +31,9 @@ test.describe('Node management', () => { // #node-type is a Radix UI combobox - click to open, then pick the option. await page.locator('#node-type').click(); await page.getByRole('option', { name: /remote/i }).click(); + // Remote nodes default to Pilot Agent mode; switch to Proxy so the api_url field renders. + await page.locator('#node-mode').click(); + await page.getByRole('button', { name: /distributed api proxy/i }).click(); // Confirm the API URL field is now visible before proceeding await expect(page.locator('#node-api-url')).toBeVisible({ timeout: 3_000 }); return true; diff --git a/frontend/src/components/NodeManager.tsx b/frontend/src/components/NodeManager.tsx index 8596b0ce..0091edcb 100644 --- a/frontend/src/components/NodeManager.tsx +++ b/frontend/src/components/NodeManager.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; import { useNodes } from '@/context/NodeContext'; -import type { Node } from '@/context/NodeContext'; +import type { Node, NodeMode } from '@/context/NodeContext'; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogTrigger } from './ui/dialog'; @@ -13,7 +13,8 @@ import { Separator } from './ui/separator'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; -import { Plus, Trash2, Wifi, WifiOff, Star, Pencil, Server, Monitor, Globe, Copy, KeyRound, Check, AlertTriangle, Calendar, RefreshCw } from 'lucide-react'; +import { Combobox } from './ui/combobox'; +import { Plus, Trash2, Wifi, WifiOff, Star, Pencil, Server, Monitor, Globe, Copy, KeyRound, Check, AlertTriangle, Calendar, RefreshCw, Terminal } from 'lucide-react'; interface NodeSchedulingSummary { active_tasks: number; @@ -41,15 +42,23 @@ function formatRelativeTime(timestamp: number): string { interface NodeFormData { name: string; type: 'local' | 'remote'; + mode: NodeMode; api_url: string; api_token: string; compose_dir: string; is_default: boolean; } +interface PilotEnrollment { + token: string; + expiresAt: number; + dockerRun: string; +} + const defaultFormData: NodeFormData = { name: '', type: 'remote', + mode: 'pilot_agent', api_url: '', api_token: '', compose_dir: '/app/compose', @@ -72,6 +81,10 @@ export function NodeManager() { const [generatingToken, setGeneratingToken] = useState(false); const [tokenCopied, setTokenCopied] = useState(false); + // Pilot enrollment state (shown after creating a pilot-agent node) + const [activeEnrollment, setActiveEnrollment] = useState<{ nodeId: number; nodeName: string; enrollment: PilotEnrollment } | null>(null); + const [enrollmentCopied, setEnrollmentCopied] = useState(false); + // Per-node scheduling summary const [nodeSummary, setNodeSummary] = useState>({}); @@ -100,13 +113,23 @@ export function NodeManager() { const err = await res.json(); throw new Error(err.error || 'Failed to create node'); } - const { id: newNodeId } = await res.json(); + const body = await res.json(); + const newNodeId: number | undefined = body.id; + const enrollment: PilotEnrollment | undefined = body.enrollment; toast.success(`Node "${formData.name}" created successfully`); - setCreateOpen(false); - setFormData(defaultFormData); - // Auto-test the new node connection immediately - if (newNodeId && formData.type === 'remote') { + const isPilot = formData.type === 'remote' && formData.mode === 'pilot_agent'; + if (isPilot && newNodeId && enrollment) { + // Keep the dialog open so the admin can copy the docker run command. + setActiveEnrollment({ nodeId: newNodeId, nodeName: formData.name, enrollment }); + } else { + setCreateOpen(false); + setFormData(defaultFormData); + } + + // Auto-test the new node connection immediately (proxy mode only; + // pilot agents flip online asynchronously once the container connects). + if (newNodeId && formData.type === 'remote' && formData.mode === 'proxy') { setTesting(newNodeId); try { const testRes = await apiFetch(`/nodes/${newNodeId}/test`, { method: 'POST' }); @@ -130,6 +153,34 @@ export function NodeManager() { } }; + const regenerateEnrollment = async (node: Node) => { + try { + const res = await apiFetch(`/nodes/${node.id}/pilot/enroll`, { method: 'POST', localOnly: true }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || 'Failed to regenerate enrollment'); + } + const { enrollment } = (await res.json()) as { enrollment: PilotEnrollment }; + setActiveEnrollment({ nodeId: node.id, nodeName: node.name, enrollment }); + toast.success('New enrollment token generated'); + } catch (error) { + toast.error((error as Error).message || 'Failed to regenerate enrollment'); + } + }; + + const copyEnrollment = async () => { + if (!activeEnrollment) return; + const text = activeEnrollment.enrollment.dockerRun; + try { + await navigator.clipboard.writeText(text); + setEnrollmentCopied(true); + toast.success('Command copied to clipboard'); + setTimeout(() => setEnrollmentCopied(false), 2000); + } catch { + toast.error('Could not copy automatically - please select and copy the command manually.'); + } + }; + const handleEdit = async () => { if (!editingNodeId) return; try { @@ -155,6 +206,7 @@ export function NodeManager() { setFormData({ name: node.name, type: node.type, + mode: (node.mode === 'pilot_agent' ? 'pilot_agent' : 'proxy'), api_url: node.api_url || '', api_token: node.api_token || '', compose_dir: node.compose_dir, @@ -302,6 +354,24 @@ export function NodeManager() { {formData.type === 'remote' && ( +
+ + setFormData({ ...formData, mode: val as NodeMode, api_url: '', api_token: '' })} + options={[ + { value: 'pilot_agent', label: 'Pilot Agent - outbound tunnel from remote host' }, + { value: 'proxy', label: 'Distributed API Proxy - primary dials the remote' }, + ]} + /> +

+ Pilot Agent requires only outbound HTTPS from the remote host. Distributed API Proxy requires the remote host to expose an inbound port. +

+
+ )} + + {formData.type === 'remote' && formData.mode === 'proxy' && ( <>
@@ -394,7 +464,10 @@ export function NodeManager() { @@ -446,6 +519,7 @@ export function NodeManager() { Name Type + Mode Endpoint Status Schedules @@ -477,8 +551,29 @@ export function NodeManager() { {node.type === 'local' ? 'Local' : 'Remote'} + + {node.type === 'local' ? ( + - + ) : node.mode === 'pilot_agent' ? ( + + + Pilot Agent + + ) : ( + + + Proxy + + )} + - {node.type === 'local' ? 'docker.sock' : (node.api_url || '-')} + {node.type === 'local' + ? 'docker.sock' + : node.mode === 'pilot_agent' + ? (node.pilot_last_seen + ? `tunnel (seen ${formatRelativeTime(node.pilot_last_seen + 60_000)} ago)` + : 'tunnel (waiting)') + : (node.api_url || '-')} {getStatusBadge(node.status)} @@ -643,11 +738,33 @@ export function NodeManager() { Edit Node {renderFormFields()} + {formData.type === 'remote' && formData.mode === 'pilot_agent' && editingNodeId !== null && ( +
+

+ Re-enroll the agent if the container was lost or the enrollment token expired. The previous tunnel is disconnected automatically. +

+ +
+ )} @@ -655,6 +772,57 @@ export function NodeManager() { + {/* Pilot enrollment dialog (create + regenerate flows both open this) */} + { + if (!open) { + setActiveEnrollment(null); + setEnrollmentCopied(false); + setCreateOpen(false); + setFormData(defaultFormData); + } + }} + > + + + Enroll the pilot agent + + {activeEnrollment && ( +
+

+ Run this command on {activeEnrollment.nodeName} to start the pilot agent. The token below is valid for 15 minutes and can only be used once. +

+
+
{activeEnrollment.enrollment.dockerRun}
+
+
+

+ Expires {formatRelativeTime(activeEnrollment.enrollment.expiresAt)} from now. +

+ +
+
+ )} + + + +
+
+ {/* Delete Confirmation */} diff --git a/frontend/src/components/ui/combobox.tsx b/frontend/src/components/ui/combobox.tsx index 8e1ea2ca..950b331e 100644 --- a/frontend/src/components/ui/combobox.tsx +++ b/frontend/src/components/ui/combobox.tsx @@ -17,6 +17,7 @@ interface ComboboxProps { emptyText?: string disabled?: boolean className?: string + id?: string } export function Combobox({ @@ -28,6 +29,7 @@ export function Combobox({ emptyText = "No results found.", disabled = false, className, + id, }: ComboboxProps) { const [open, setOpen] = React.useState(false) const [search, setSearch] = React.useState("") @@ -95,6 +97,7 @@ export function Combobox({