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

* feat: pilot agent outbound-mode for remote nodes

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

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

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

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

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

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

The Combobox trigger button had no id, leaving its Label orphaned and
making getByRole name-based lookups fail. Adding id to the primitive,
passing id="node-mode" from NodeManager, and updating the E2E helper to
use #node-mode fixes both the a11y regression and the CI timeout.
This commit is contained in:
Anso
2026-04-17 20:31:43 -04:00
committed by GitHub
parent 0a536ae653
commit 8e7a567f69
15 changed files with 1812 additions and 27 deletions
+49 -2
View File
@@ -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 {