Skip to content

Commit 5773dfd

Browse files
parsakhazrunpane
authored andcommitted
fix: harden remote daemon HTTP transport
1 parent b4d4d9b commit 5773dfd

8 files changed

Lines changed: 404 additions & 15 deletions

File tree

main/src/daemon/bootstrap.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { TaskQueue } from '../services/taskQueue';
2121
import { registerIpcHandlers } from '../ipc';
2222
import { PaneDaemonServer } from './server';
2323
import { PaneRemoteHttpApiServer } from './httpApiServer';
24+
import { PaneRemoteTransportController } from './remoteTransportController';
2425
import { createFanoutEventSink, noopPaneEventSink, type PaneEventSink } from '../core/eventSink';
2526
import {
2627
setPaneRuntime,
@@ -208,30 +209,29 @@ export async function createPaneDaemonHost(options: PaneDaemonHostOptions): Prom
208209
const commandRegistry = registerIpcHandlers(services);
209210

210211
let paneDaemonServer: PaneDaemonServer | null = null;
211-
let remoteHttpApiServer: PaneRemoteHttpApiServer | null = null;
212+
const remoteTransportController = new PaneRemoteTransportController(commandRegistry, configManager);
212213
try {
213214
paneDaemonServer = new PaneDaemonServer(commandRegistry, getAppDirectory());
214215
await paneDaemonServer.start();
215216
} catch (error) {
216217
console.error('[Pane daemon] Failed to start local daemon server; continuing with renderer-only runtime events', error);
217218
}
218219

219-
if (startRemoteTransport && configManager.getConfig().remoteDaemon?.host.config.enabled) {
220+
if (startRemoteTransport) {
221+
remoteTransportController.startWatchingConfig();
220222
try {
221-
remoteHttpApiServer = new PaneRemoteHttpApiServer(commandRegistry, configManager);
222-
await remoteHttpApiServer.start();
223+
await remoteTransportController.syncToConfig();
223224
} catch (error) {
224225
console.error('[Pane remote daemon] Failed to start remote HTTP transport; continuing without remote access', error);
225-
remoteHttpApiServer = null;
226226
}
227227
}
228228

229229
const daemonSinks: PaneEventSink[] = [];
230230
if (paneDaemonServer) {
231231
daemonSinks.push(paneDaemonServer.getEventSink());
232232
}
233-
if (remoteHttpApiServer) {
234-
daemonSinks.push(remoteHttpApiServer.getEventSink());
233+
if (startRemoteTransport) {
234+
daemonSinks.push(remoteTransportController.getEventSink());
235235
}
236236

237237
installPaneRuntime(
@@ -269,7 +269,9 @@ export async function createPaneDaemonHost(options: PaneDaemonHostOptions): Prom
269269
daemonServices,
270270
commandRegistry,
271271
paneDaemonServer,
272-
remoteHttpApiServer,
272+
get remoteHttpApiServer() {
273+
return remoteTransportController.getServer();
274+
},
273275
permissionIpcServer,
274276
async shutdown(): Promise<void> {
275277
resourceMonitorService.stop();
@@ -281,7 +283,7 @@ export async function createPaneDaemonHost(options: PaneDaemonHostOptions): Prom
281283
await cliManagerFactory.shutdown();
282284
await taskQueue.close();
283285
await permissionIpcServer?.stop();
284-
await remoteHttpApiServer?.stop();
286+
await remoteTransportController.stopWatchingAndShutdown();
285287
if (paneDaemonServer) {
286288
await paneDaemonServer.stop();
287289
}

main/src/daemon/httpApiServer.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,4 +338,16 @@ describe('PaneRemoteHttpApiServer', () => {
338338

339339
await expect(server.start()).rejects.toThrow('Remote daemon HTTP API loopback transport is disabled by config');
340340
});
341+
342+
it('refuses direct HTTP on non-loopback listen hosts', async () => {
343+
const registry = new PaneCommandRegistry();
344+
const server = new PaneRemoteHttpApiServer(
345+
registry,
346+
createConfigManagerStub(createEnabledRemoteConfig({ listenHost: '0.0.0.0' })) as never,
347+
);
348+
349+
await expect(server.start()).rejects.toThrow(
350+
'Remote daemon direct HTTP only supports loopback listen hosts; keep listenHost on 127.0.0.1, ::1, or localhost and expose it through an SSH tunnel, Tailscale/VPN, or a reverse proxy.',
351+
);
352+
});
341353
});

main/src/daemon/httpApiServer.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { authenticateRemoteDaemonBearerToken } from './auth';
66
import { isPaneDaemonEventChannel } from './server';
77
import {
88
createDefaultRemoteDaemonConfig,
9+
getRemoteDaemonHostConfigValidationError,
910
type RemoteDaemonConfig,
1011
type RemoteDaemonEventEnvelope,
1112
type RemoteInvokeRequest,
@@ -115,8 +116,9 @@ export class PaneRemoteHttpApiServer {
115116
throw new Error('Remote daemon HTTP API server is disabled in config');
116117
}
117118

118-
if (isLoopbackHost(hostConfig.listenHost) && !hostConfig.allowInsecureHttpOnLoopback) {
119-
throw new Error('Remote daemon HTTP API loopback transport is disabled by config');
119+
const hostConfigError = getRemoteDaemonHostConfigValidationError(hostConfig);
120+
if (hostConfigError) {
121+
throw new Error(hostConfigError);
120122
}
121123

122124
const server = http.createServer((request, response) => {
@@ -431,10 +433,6 @@ function writeSseEvent(response: ServerResponse, eventName: string, payload: Rem
431433
response.write(`data: ${JSON.stringify(payload)}\n\n`);
432434
}
433435

434-
function isLoopbackHost(host: string): boolean {
435-
return host === '127.0.0.1' || host === '::1' || host === 'localhost';
436-
}
437-
438436
function isRemoteInvokeRequest(value: unknown): value is RemoteInvokeRequest {
439437
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
440438
return false;
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import http from 'http';
2+
import { EventEmitter } from 'events';
3+
import { afterEach, describe, expect, it } from 'vitest';
4+
import { createDefaultRemoteDaemonConfig, type RemoteDaemonConfig } from '../../../shared/types/remoteDaemon';
5+
import { hashRemoteDaemonToken } from './auth';
6+
import { PaneCommandRegistry } from './commandRegistry';
7+
import { PaneRemoteTransportController } from './remoteTransportController';
8+
9+
interface TestEventStream {
10+
close(): void;
11+
nextEvent(timeoutMs?: number): Promise<{ event: string | null; data: string[] }>;
12+
}
13+
14+
class ConfigManagerStub extends EventEmitter {
15+
private remoteDaemon: RemoteDaemonConfig;
16+
17+
constructor(initialConfig: RemoteDaemonConfig) {
18+
super();
19+
this.remoteDaemon = initialConfig;
20+
}
21+
22+
getConfig(): { remoteDaemon: RemoteDaemonConfig } {
23+
return { remoteDaemon: this.remoteDaemon };
24+
}
25+
26+
async updateRemoteDaemonConfig(remoteDaemon: RemoteDaemonConfig): Promise<void> {
27+
this.remoteDaemon = remoteDaemon;
28+
this.emit('config-updated', { remoteDaemon });
29+
}
30+
}
31+
32+
const activeControllers: PaneRemoteTransportController[] = [];
33+
const activeRequests = new Set<http.ClientRequest>();
34+
35+
afterEach(async () => {
36+
for (const request of activeRequests) {
37+
request.destroy();
38+
}
39+
activeRequests.clear();
40+
41+
for (const controller of activeControllers.splice(0)) {
42+
await controller.stopWatchingAndShutdown();
43+
}
44+
});
45+
46+
function createEnabledRemoteConfig(overrides?: Partial<RemoteDaemonConfig['host']['config']>): RemoteDaemonConfig {
47+
const config = createDefaultRemoteDaemonConfig();
48+
config.host.config = {
49+
...config.host.config,
50+
enabled: true,
51+
listenHost: '127.0.0.1',
52+
listenPort: 0,
53+
...overrides,
54+
};
55+
config.host.clients = [{
56+
id: 'client-1',
57+
label: 'Mac mini',
58+
createdAt: new Date('2026-05-14T00:00:00.000Z').toISOString(),
59+
tokenHash: hashRemoteDaemonToken('secret-token'),
60+
}];
61+
return config;
62+
}
63+
64+
async function openEventStream(server: NonNullable<ReturnType<PaneRemoteTransportController['getServer']>>, token: string): Promise<TestEventStream> {
65+
const address = server.getAddress();
66+
if (!address) {
67+
throw new Error('Remote HTTP API server is not listening');
68+
}
69+
70+
return new Promise((resolve, reject) => {
71+
const request = http.request({
72+
host: address.host,
73+
port: address.port,
74+
path: '/events',
75+
method: 'GET',
76+
headers: {
77+
Authorization: `Bearer ${token}`,
78+
},
79+
});
80+
81+
activeRequests.add(request);
82+
request.once('error', reject);
83+
request.on('response', (response) => {
84+
const queuedEvents: Array<{ event: string | null; data: string[] }> = [];
85+
const waiters: Array<(event: { event: string | null; data: string[] }) => void> = [];
86+
let buffer = '';
87+
88+
response.on('data', (chunk) => {
89+
buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
90+
91+
let boundaryIndex = buffer.indexOf('\n\n');
92+
while (boundaryIndex !== -1) {
93+
const rawEvent = buffer.slice(0, boundaryIndex);
94+
buffer = buffer.slice(boundaryIndex + 2);
95+
96+
const parsedEvent = parseSseEvent(rawEvent);
97+
if (parsedEvent) {
98+
const waiter = waiters.shift();
99+
if (waiter) {
100+
waiter(parsedEvent);
101+
} else {
102+
queuedEvents.push(parsedEvent);
103+
}
104+
}
105+
106+
boundaryIndex = buffer.indexOf('\n\n');
107+
}
108+
});
109+
110+
resolve({
111+
close() {
112+
request.destroy();
113+
},
114+
nextEvent(timeoutMs = 1000) {
115+
if (queuedEvents.length > 0) {
116+
return Promise.resolve(queuedEvents.shift() as { event: string | null; data: string[] });
117+
}
118+
119+
return new Promise((eventResolve, eventReject) => {
120+
const timeout = setTimeout(() => {
121+
eventReject(new Error('Timed out waiting for SSE event'));
122+
}, timeoutMs);
123+
124+
waiters.push((event) => {
125+
clearTimeout(timeout);
126+
eventResolve(event);
127+
});
128+
});
129+
},
130+
});
131+
});
132+
133+
request.end();
134+
});
135+
}
136+
137+
function parseSseEvent(rawEvent: string): { event: string | null; data: string[] } | null {
138+
const lines = rawEvent.split('\n');
139+
let event: string | null = null;
140+
const data: string[] = [];
141+
142+
for (const line of lines) {
143+
if (line.startsWith('event: ')) {
144+
event = line.slice('event: '.length);
145+
continue;
146+
}
147+
148+
if (line.startsWith('data: ')) {
149+
data.push(line.slice('data: '.length));
150+
}
151+
}
152+
153+
if (!event && data.length === 0) {
154+
return null;
155+
}
156+
157+
return { event, data };
158+
}
159+
160+
async function waitFor(predicate: () => boolean, timeoutMs = 1500): Promise<void> {
161+
const startedAt = Date.now();
162+
while (!predicate()) {
163+
if (Date.now() - startedAt > timeoutMs) {
164+
throw new Error('Timed out waiting for condition');
165+
}
166+
167+
await new Promise((resolve) => setTimeout(resolve, 10));
168+
}
169+
}
170+
171+
describe('PaneRemoteTransportController', () => {
172+
it('starts and stops remote HTTP transport on config updates while keeping a stable event sink', async () => {
173+
const registry = new PaneCommandRegistry();
174+
const configManager = new ConfigManagerStub(createDefaultRemoteDaemonConfig());
175+
const controller = new PaneRemoteTransportController(registry, configManager as never);
176+
activeControllers.push(controller);
177+
controller.startWatchingConfig();
178+
179+
const daemonEventSink = controller.getEventSink();
180+
await controller.syncToConfig();
181+
expect(controller.getServer()).toBeNull();
182+
183+
await configManager.updateRemoteDaemonConfig(createEnabledRemoteConfig());
184+
await waitFor(() => controller.getServer() !== null);
185+
186+
const server = controller.getServer();
187+
if (!server) {
188+
throw new Error('Remote HTTP API server did not start');
189+
}
190+
191+
const stream = await openEventStream(server, 'secret-token');
192+
await stream.nextEvent();
193+
194+
daemonEventSink.send('session:created', { id: 'session-1' });
195+
196+
const daemonEvent = await stream.nextEvent();
197+
expect(daemonEvent.event).toBe('daemon-event');
198+
expect(JSON.parse(daemonEvent.data.join('\n'))).toEqual({
199+
channel: 'session:created',
200+
args: [{ id: 'session-1' }],
201+
timestamp: expect.any(String),
202+
});
203+
204+
stream.close();
205+
await configManager.updateRemoteDaemonConfig(createDefaultRemoteDaemonConfig());
206+
await waitFor(() => controller.getServer() === null);
207+
});
208+
209+
it('stops the active remote HTTP transport when config changes to an invalid non-loopback bind', async () => {
210+
const registry = new PaneCommandRegistry();
211+
const configManager = new ConfigManagerStub(createEnabledRemoteConfig());
212+
const controller = new PaneRemoteTransportController(registry, configManager as never);
213+
activeControllers.push(controller);
214+
controller.startWatchingConfig();
215+
216+
await controller.syncToConfig();
217+
expect(controller.getServer()).not.toBeNull();
218+
219+
await configManager.updateRemoteDaemonConfig(createEnabledRemoteConfig({ listenHost: '0.0.0.0' }));
220+
await waitFor(() => controller.getServer() === null);
221+
});
222+
});

0 commit comments

Comments
 (0)