From bf9dea429b2b2adae3dc1399666809ff2066a5be Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 29 Oct 2026 07:33:03 +0100 Subject: [PATCH] fix(server): apply proxy policy to connected host links --- docs/architecture.md | 2 +- packages/client/src/node/index.ts | 20 +++++++++++++------ .../src/server/connected-hosts/index.ts | 2 ++ .../server/src/server/network/network.test.ts | 18 +++++++++++++++++ 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index a22130e211..a7ad69c14d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -496,7 +496,7 @@ Worker restart retains supervisor arguments/environment and rereads the configur ## Outbound networking The daemon and its plugin processes honor `HTTPS_PROXY`, `HTTP_PROXY`, and `NO_PROXY` -(including lowercase forms, which take precedence). Fetch and relay/Hub WebSockets +(including lowercase forms, which take precedence). Fetch and relay, Hub, and connected-host WebSockets share this policy. Loopback (`localhost`, `127.0.0.1`, `::1`) always goes direct. A proxy without a scheme uses HTTP. Invalid values and unsupported proxy schemes are treated as unset, with one warning naming the variable in `daemon.log`. diff --git a/packages/client/src/node/index.ts b/packages/client/src/node/index.ts index 7132d09f10..46b5cff17a 100644 --- a/packages/client/src/node/index.ts +++ b/packages/client/src/node/index.ts @@ -2,6 +2,7 @@ export { createHostChannelTransport } from "../hosts/index.js"; import { HostTransportBudget } from "./buffering.js"; export { HostTransportBudget } from "./buffering.js"; import { homedir } from "node:os"; +import type { Agent } from "node:http"; import { WebSocket } from "ws"; import { parseHostTarget, type HostTarget } from "@getpaseo/protocol/host-target"; import { createSshTunnel, type SshTunnel } from "./ssh-tunnel.js"; @@ -19,10 +20,15 @@ export { parseHostTarget }; export type { SshTunnel } from "./ssh-tunnel.js"; export { createSshTunnel, resolveSshFailureDetail } from "./ssh-tunnel.js"; +export interface HostTransportOptions { + webSocketAgent?: (url: string) => Agent | undefined; +} + /** A factory owns one attempt, including a fresh SSH process and its teardown. */ export function createHostTransportFactory( connection: string, budget = new HostTransportBudget(), + options: HostTransportOptions = {}, ): DaemonTransportFactory { const target = parseHostTarget(connection); // Rises to "protected" once the daemon negotiates it, so a later attempt @@ -68,12 +74,13 @@ export function createHostTransportFactory( return; } const socketPath = socketTarget.socketPath?.replace(/^~(?=\/)/, homedir()); - let factory = createWebSocketTransportFactory((url, options) => { + let factory = createWebSocketTransportFactory((url, socketOptions) => { const socket = new WebSocket( socketPath && url.startsWith("ws+unix:") ? `ws+unix://${socketPath}:/ws` : url, - options?.protocols, + socketOptions?.protocols, { - headers: options?.headers, + headers: socketOptions?.headers, + agent: socketPath ? undefined : options.webSocketAgent?.(url), ...(socketPath ? { socketPath } : {}), maxPayload: 100 * 1024 * 1024, }, @@ -191,14 +198,15 @@ export function createHostTransportFactory( } export function createHostClient( connection: string, - config: Omit, + config: Omit & HostTransportOptions, budget?: HostTransportBudget, ): DaemonClient { const target = parseHostTarget(connection); + const { webSocketAgent, ...clientConfig } = config; return new DaemonClient({ - ...config, + ...clientConfig, url: "ws://host-connector/ws", ...(target.password ? { password: target.password } : {}), - transportFactory: createHostTransportFactory(connection, budget), + transportFactory: createHostTransportFactory(connection, budget, { webSocketAgent }), }); } diff --git a/packages/server/src/server/connected-hosts/index.ts b/packages/server/src/server/connected-hosts/index.ts index 1a8f7c2731..96d6ac35d2 100644 --- a/packages/server/src/server/connected-hosts/index.ts +++ b/packages/server/src/server/connected-hosts/index.ts @@ -21,6 +21,7 @@ import { type HostUpdate, } from "@getpaseo/protocol/hosts"; import type { DaemonPermission, WSHelloMessage } from "@getpaseo/protocol/messages"; +import { webSocketProxyAgent } from "../network/index.js"; export class HostError extends Error { constructor( @@ -242,6 +243,7 @@ export class ConnectedHosts { { clientId: `host-link-${this.self.serverId}-${row.registrationId}`, connectTimeoutMs: 10000, + webSocketAgent: webSocketProxyAgent, }, this.budget, ); diff --git a/packages/server/src/server/network/network.test.ts b/packages/server/src/server/network/network.test.ts index a35ce91fe8..2127127724 100644 --- a/packages/server/src/server/network/network.test.ts +++ b/packages/server/src/server/network/network.test.ts @@ -9,6 +9,7 @@ import { afterEach, expect, test } from "vitest"; import { WebSocket, WebSocketServer } from "ws"; import { getGlobalDispatcher, setGlobalDispatcher } from "undici"; import { initializeEnvironmentProxy, webSocketProxyAgent } from "./index.js"; +import { ConnectedHosts } from "../connected-hosts/index.js"; const original = getGlobalDispatcher(); const servers: Server[] = []; afterEach(async () => { @@ -223,3 +224,20 @@ test("case-insensitive environment access reports a variable only once", () => { ); expect(initializeEnvironmentProxy(env)).toEqual(["https_proxy"]); }); + +test("connected-host links send remote WebSockets through the environment proxy", async () => { + const recorder = await proxy(); + initializeEnvironmentProxy({ HTTPS_PROXY: recorder.url }); + const home = await mkdtemp(path.join(os.tmpdir(), "connected-host-proxy-")); + const hosts = new ConnectedHosts(home, { serverId: "self", label: "Self" }); + try { + await hosts.add( + { connection: "wss://remote.invalid/ws" }, + { sourceId: "test", manageSources: true }, + ); + await expect.poll(() => recorder.tunnels).toContain("remote.invalid:443"); + } finally { + await hosts.close(); + await rm(home, { recursive: true, force: true }); + } +});