feat: route code queue egress through provider gateway

This commit is contained in:
Codex
2026-05-15 02:51:49 +00:00
parent c206f13216
commit a7e9ecc32f
10 changed files with 602 additions and 6 deletions
+112
View File
@@ -2,6 +2,7 @@ import type { Server, ServerWebSocket } from "bun";
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { mkdir, rm, stat } from "node:fs/promises";
import { connect as connectTcp, type Socket } from "node:net";
import { basename, dirname, relative, resolve, posix as pathPosix } from "node:path";
import postgres from "postgres";
import { createHourlyJsonlWriter, logRetentionBytesForService } from "../../shared/src/rotating-jsonl";
@@ -17,7 +18,13 @@ import {
type CoreHostSshOpenMessage,
type CoreHostSshResizeMessage,
type CoreDispatchMessage,
type CoreEgressTcpCloseMessage,
type CoreEgressTcpDataMessage,
type CoreEgressTcpOpenedMessage,
type JsonValue,
type ProviderEgressTcpCloseMessage,
type ProviderEgressTcpDataMessage,
type ProviderEgressTcpOpenMessage,
type ProviderHostSshDataMessage,
type ProviderHostSshErrorMessage,
type ProviderHostSshExitMessage,
@@ -178,6 +185,13 @@ type ScheduleAction = DispatchScheduleAction | PgdataBackupScheduleAction;
type TaskTerminalWaiter = (task: RawTaskRow | null) => void;
interface EgressTcpConnection {
providerId: string;
connectionId: string;
socket: Socket;
provider: ProviderSocket;
}
interface MicroserviceProxyCacheEntry {
expiresAt: number;
staleExpiresAt: number;
@@ -201,6 +215,7 @@ interface MicroserviceAvailabilityEntry {
const recentLogs: unknown[] = [];
const activeProviders = new Map<string, ProviderSocket>();
const activeSshClients = new Map<string, ProviderSocket>();
const activeEgressTcpConnections = new Map<string, EgressTcpConnection>();
const serviceStartedAt = new Date();
const config = readConfig();
const logger = createLogger("backend-core", config.logFile);
@@ -977,6 +992,89 @@ function wsSendJson(ws: ProviderSocket, value: unknown): void {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(value));
}
function egressTcpKey(providerId: string, connectionId: string): string {
return `${providerId}:${connectionId}`;
}
function isValidEgressHost(host: string): boolean {
return host.length > 0 && host.length <= 253 && !/[\s/\\:@\0]/u.test(host);
}
function isValidEgressPort(port: number): boolean {
return Number.isInteger(port) && port > 0 && port <= 65_535;
}
function sendEgressClose(provider: ProviderSocket, connectionId: string, error?: string): void {
const message: CoreEgressTcpCloseMessage = error === undefined
? { type: "egress_tcp_close", connectionId }
: { type: "egress_tcp_close", connectionId, error };
wsSendJson(provider, message);
}
function closeEgressTcpConnection(providerId: string, connectionId: string, error?: string): void {
const key = egressTcpKey(providerId, connectionId);
const connection = activeEgressTcpConnections.get(key);
if (connection === undefined) return;
activeEgressTcpConnections.delete(key);
connection.socket.destroy();
if (error !== undefined) sendEgressClose(connection.provider, connectionId, error);
}
function handleEgressTcpOpen(ws: ProviderSocket, message: ProviderEgressTcpOpenMessage): void {
const host = message.host.trim();
const port = Number(message.port);
if (!isValidEgressHost(host) || !isValidEgressPort(port)) {
sendEgressClose(ws, message.connectionId, "invalid egress target");
return;
}
const key = egressTcpKey(message.providerId, message.connectionId);
closeEgressTcpConnection(message.providerId, message.connectionId);
const socket = connectTcp({ host, port });
const connection: EgressTcpConnection = { providerId: message.providerId, connectionId: message.connectionId, socket, provider: ws };
activeEgressTcpConnections.set(key, connection);
socket.on("connect", () => {
const opened: CoreEgressTcpOpenedMessage = { type: "egress_tcp_opened", connectionId: message.connectionId };
wsSendJson(ws, opened);
});
socket.on("data", (chunk) => {
const data: CoreEgressTcpDataMessage = {
type: "egress_tcp_data",
connectionId: message.connectionId,
data: chunk.toString("base64"),
encoding: "base64",
};
wsSendJson(ws, data);
});
socket.on("close", () => {
if (activeEgressTcpConnections.get(key) !== connection) return;
activeEgressTcpConnections.delete(key);
sendEgressClose(ws, message.connectionId);
});
socket.on("error", (error) => {
if (activeEgressTcpConnections.get(key) !== connection) return;
activeEgressTcpConnections.delete(key);
sendEgressClose(ws, message.connectionId, error.message);
});
}
function handleEgressTcpData(message: ProviderEgressTcpDataMessage): void {
const connection = activeEgressTcpConnections.get(egressTcpKey(message.providerId, message.connectionId));
if (connection === undefined) return;
connection.socket.write(Buffer.from(message.data, message.encoding === "base64" ? "base64" : "utf8"));
}
function handleEgressTcpClose(message: ProviderEgressTcpCloseMessage): void {
closeEgressTcpConnection(message.providerId, message.connectionId);
}
function closeEgressTcpConnectionsForProvider(providerId: string): void {
for (const [key, connection] of activeEgressTcpConnections) {
if (connection.providerId !== providerId) continue;
activeEgressTcpConnections.delete(key);
connection.socket.destroy();
}
}
function sshClientFor(sessionId: string): ProviderSocket | null {
return activeSshClients.get(sessionId) ?? null;
}
@@ -1031,6 +1129,19 @@ async function handleProviderMessage(ws: ProviderSocket, raw: string | Buffer):
return;
}
if (message.type === "egress_tcp_open") {
handleEgressTcpOpen(ws, message);
return;
}
if (message.type === "egress_tcp_data") {
handleEgressTcpData(message);
return;
}
if (message.type === "egress_tcp_close") {
handleEgressTcpClose(message);
return;
}
if (message.type === "register") {
const labels = { ...message.labels, unideskCapabilities: message.capabilities };
await upsertNodeOnline(message.providerId, message.name, labels);
@@ -3499,6 +3610,7 @@ const providerServer = Bun.serve<WsData>({
const providerId = ws.data.providerId;
logger("warn", "provider_socket_close", { providerId: providerId ?? null });
if (providerId !== undefined) {
closeEgressTcpConnectionsForProvider(providerId);
if (activeProviders.get(providerId) !== ws) {
logger("info", "provider_socket_close_ignored_replaced", { providerId });
return;
@@ -40,6 +40,17 @@ services:
CODE_QUEUE_DEV_CONTAINER_DEFAULT_PROVIDER_ID: "${CODE_QUEUE_DEV_CONTAINER_DEFAULT_PROVIDER_ID:-D601}"
CODE_QUEUE_DEV_CONTAINER_IMAGE: "${CODE_QUEUE_DEV_CONTAINER_IMAGE:-}"
CODE_QUEUE_DEV_CONTAINER_WORKDIR: "${CODE_QUEUE_DEV_CONTAINER_WORKDIR:-/home/ubuntu}"
CODE_QUEUE_EGRESS_PROXY_ENABLED: "${CODE_QUEUE_EGRESS_PROXY_ENABLED:-true}"
CODE_QUEUE_EGRESS_PROXY_URL: "${CODE_QUEUE_EGRESS_PROXY_URL:-http://unidesk-provider-gateway-D601:18789}"
CODE_QUEUE_EGRESS_PROXY_NO_PROXY: "${CODE_QUEUE_EGRESS_PROXY_NO_PROXY:-localhost,127.0.0.1,::1,host.docker.internal,unidesk-provider-gateway-D601,74.48.78.17,backend-core,oa-event-flow,database}"
HTTP_PROXY: "${CODE_QUEUE_EGRESS_PROXY_URL:-http://unidesk-provider-gateway-D601:18789}"
HTTPS_PROXY: "${CODE_QUEUE_EGRESS_PROXY_URL:-http://unidesk-provider-gateway-D601:18789}"
ALL_PROXY: "${CODE_QUEUE_EGRESS_PROXY_URL:-http://unidesk-provider-gateway-D601:18789}"
http_proxy: "${CODE_QUEUE_EGRESS_PROXY_URL:-http://unidesk-provider-gateway-D601:18789}"
https_proxy: "${CODE_QUEUE_EGRESS_PROXY_URL:-http://unidesk-provider-gateway-D601:18789}"
all_proxy: "${CODE_QUEUE_EGRESS_PROXY_URL:-http://unidesk-provider-gateway-D601:18789}"
NO_PROXY: "${CODE_QUEUE_EGRESS_PROXY_NO_PROXY:-localhost,127.0.0.1,::1,host.docker.internal,unidesk-provider-gateway-D601,74.48.78.17,backend-core,oa-event-flow,database}"
no_proxy: "${CODE_QUEUE_EGRESS_PROXY_NO_PROXY:-localhost,127.0.0.1,::1,host.docker.internal,unidesk-provider-gateway-D601,74.48.78.17,backend-core,oa-event-flow,database}"
CODE_QUEUE_WINDOWS_NATIVE_CODEX_DEFAULT_WORKDIR: "${CODE_QUEUE_WINDOWS_NATIVE_CODEX_DEFAULT_WORKDIR:-/mnt/f/Work/ConStart}"
CODE_QUEUE_WINDOWS_NATIVE_CODEX_BRIDGE_DIR: "${CODE_QUEUE_WINDOWS_NATIVE_CODEX_BRIDGE_DIR:-/home/ubuntu/.unidesk/code-queue/windows-native-codex}"
CODE_QUEUE_WINDOWS_NATIVE_CODEX_COMMAND: "${CODE_QUEUE_WINDOWS_NATIVE_CODEX_COMMAND:-codex app-server --listen stdio://}"
@@ -66,8 +77,16 @@ services:
- ${CODE_QUEUE_STATE_DIR:-../../../../.state/code-queue}:/var/lib/unidesk/code-queue
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- default
- provider-gateway
healthcheck:
test: ["CMD-SHELL", "curl -fsS --max-time 2 http://127.0.0.1:4222/health >/dev/null"]
interval: 5s
timeout: 3s
retries: 20
networks:
provider-gateway:
external: true
name: ${CODE_QUEUE_PROVIDER_GATEWAY_NETWORK:-unidesk-provider-d601_default}
@@ -169,6 +169,18 @@ const config = readConfig();
const logger = createLogger("code-queue", config.logFile);
configureOaEvents({ baseUrl: config.oaEventFlowBaseUrl, logger, nowIso });
const providerGatewayEgressProxy = configureProviderGatewayEgressProxy();
if (providerGatewayEgressProxy.enabled) {
const proxyEnv = providerGatewayEgressProxy.proxyUrl;
process.env.HTTP_PROXY = proxyEnv;
process.env.HTTPS_PROXY = proxyEnv;
process.env.http_proxy = proxyEnv;
process.env.https_proxy = proxyEnv;
process.env.ALL_PROXY = proxyEnv;
process.env.all_proxy = proxyEnv;
process.env.NO_PROXY = providerGatewayEgressProxy.noProxy;
process.env.no_proxy = providerGatewayEgressProxy.noProxy;
}
const state = emptyState();
let processing = false;
const processingQueues = new Set<string>();
@@ -283,6 +295,9 @@ function readConfig(): RuntimeConfig {
const mainProviderId = envString("CODE_QUEUE_MAIN_PROVIDER_ID", "D601");
const devContainerDefaultProviderId = envString("CODE_QUEUE_DEV_CONTAINER_DEFAULT_PROVIDER_ID", "D601");
const remoteDefaultWorkdir = envString("CODE_QUEUE_REMOTE_WORKDIR", "/home/ubuntu");
const defaultWorkdir = envString("CODE_QUEUE_WORKDIR", "/workspace");
const devContainerMasterHost = envString("CODE_QUEUE_DEV_CONTAINER_MASTER_HOST", "74.48.78.17");
const defaultProviderGatewayProxyHost = `unidesk-provider-gateway-${mainProviderId}`;
const executionProviderIds = Array.from(new Set([
mainProviderId,
...envList("CODE_QUEUE_EXECUTION_PROVIDER_IDS", [devContainerDefaultProviderId]),
@@ -293,7 +308,7 @@ function readConfig(): RuntimeConfig {
dataDir,
outputArchiveDir: envString("CODE_QUEUE_OUTPUT_ARCHIVE_DIR", resolve(dataDir, "output-archive")),
logFile: envString("LOG_FILE", "/var/log/unidesk/code-queue.jsonl"),
defaultWorkdir: envString("CODE_QUEUE_WORKDIR", "/workspace"),
defaultWorkdir,
mainProviderId,
remoteDefaultWorkdir,
executionProviderIds,
@@ -339,7 +354,21 @@ function readConfig(): RuntimeConfig {
maxInMemoryOutputRecords: envNonNegativeNumber("CODE_QUEUE_IN_MEMORY_OUTPUT_RECORDS", 10),
maxInMemoryEventRecords: envNonNegativeNumber("CODE_QUEUE_IN_MEMORY_EVENT_RECORDS", 10),
maxActiveQueues: Math.max(0, Math.min(32, envNumber("CODE_QUEUE_MAX_ACTIVE_QUEUES", 0))),
devContainerMasterHost: envString("CODE_QUEUE_DEV_CONTAINER_MASTER_HOST", "74.48.78.17"),
egressProxyEnabled: envBool("CODE_QUEUE_EGRESS_PROXY_ENABLED", true),
egressProxyUrl: envString("CODE_QUEUE_EGRESS_PROXY_URL", `http://${defaultProviderGatewayProxyHost}:18789`).replace(/\/+$/u, ""),
egressProxyNoProxy: envString("CODE_QUEUE_EGRESS_PROXY_NO_PROXY", [
"localhost",
"127.0.0.1",
"::1",
"host.docker.internal",
defaultProviderGatewayProxyHost,
devContainerMasterHost,
"74.48.78.17",
"backend-core",
"oa-event-flow",
"database",
].join(",")),
devContainerMasterHost,
devContainerDefaultProviderId,
devContainerImage: envString("CODE_QUEUE_DEV_CONTAINER_IMAGE", ""),
devContainerWorkdir: envString("CODE_QUEUE_DEV_CONTAINER_WORKDIR", remoteDefaultWorkdir),
@@ -351,6 +380,48 @@ function readConfig(): RuntimeConfig {
};
}
function configureProviderGatewayEgressProxy(): { enabled: boolean; proxyUrl: string; noProxy: string; channel: string } {
if (!config.egressProxyEnabled) {
return { enabled: false, proxyUrl: "", noProxy: config.egressProxyNoProxy, channel: "provider-gateway" };
}
return {
enabled: true,
proxyUrl: config.egressProxyUrl,
noProxy: config.egressProxyNoProxy,
channel: "provider-gateway",
};
}
async function providerGatewayEgressProxyStatus(): Promise<JsonValue> {
if (!providerGatewayEgressProxy.enabled) return { enabled: false, channel: "provider-gateway" };
const base = {
enabled: true,
channel: providerGatewayEgressProxy.channel,
proxyUrl: providerGatewayEgressProxy.proxyUrl,
noProxy: providerGatewayEgressProxy.noProxy,
};
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 600);
try {
const url = new URL(providerGatewayEgressProxy.proxyUrl);
url.pathname = "/__unidesk/egress-proxy/health";
url.search = "";
const response = await fetch(url, { signal: controller.signal });
const bodyText = await response.text();
let upstream: JsonValue = bodyText.slice(0, 1000);
try {
upstream = JSON.parse(bodyText) as JsonValue;
} catch {
// Keep the bounded body text as evidence if the proxy returned a non-JSON failure page.
}
return { ...base, connected: response.ok, status: response.status, upstream };
} catch (error) {
return { ...base, connected: false, error: error instanceof Error ? error.message : String(error) };
} finally {
clearTimeout(timer);
}
}
function createLogger(service: string, logFile: string) {
const writer = createHourlyJsonlWriter({
baseLogFile: logFile,
@@ -3689,7 +3760,13 @@ async function route(req: Request): Promise<Response> {
const url = new URL(req.url);
if (req.method === "OPTIONS") return jsonResponse({ ok: true });
try {
if (url.pathname === "/" || url.pathname === "/health") return jsonResponse({ ok: true, service: "code-queue", queue: await queueSummaryForHealth(false), startedAt: serviceStartedAt });
if (url.pathname === "/" || url.pathname === "/health") return jsonResponse({
ok: true,
service: "code-queue",
queue: await queueSummaryForHealth(false),
egressProxy: await providerGatewayEgressProxyStatus(),
startedAt: serviceStartedAt,
});
if (url.pathname === "/logs") return jsonResponse({ ok: true, logs: recentLogs.slice(-parseLimit(url)) });
if (url.pathname === "/api/events" && req.method === "GET") return jsonResponse({ ok: false, error: "Code Queue private SSE was removed; subscribe to oa-event-flow /api/events/stream with service:code-queue tags." }, 410);
if (url.pathname === "/api/dev-ready" && req.method === "GET") return jsonResponse({ ok: true, devReady: collectDevReady() });
@@ -80,6 +80,9 @@ export interface RuntimeConfig {
maxInMemoryOutputRecords: number;
maxInMemoryEventRecords: number;
maxActiveQueues: number;
egressProxyEnabled: boolean;
egressProxyUrl: string;
egressProxyNoProxy: string;
devContainerMasterHost: string;
devContainerDefaultProviderId: string;
devContainerImage: string;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@unidesk/provider-gateway",
"version": "0.2.17",
"version": "0.2.18",
"private": true,
"type": "module",
"scripts": {
@@ -0,0 +1,268 @@
import { createServer, type Server, type Socket } from "node:net";
import type {
CoreEgressTcpCloseMessage,
CoreEgressTcpDataMessage,
CoreEgressTcpOpenedMessage,
JsonValue,
ProviderEgressTcpCloseMessage,
ProviderEgressTcpDataMessage,
ProviderEgressTcpOpenMessage,
} from "../../shared/src/index";
type Logger = (level: "debug" | "info" | "warn" | "error", message: string, data?: JsonValue) => void;
type EgressToCoreMessage = ProviderEgressTcpOpenMessage | ProviderEgressTcpDataMessage | ProviderEgressTcpCloseMessage;
type EgressFromCoreMessage = CoreEgressTcpOpenedMessage | CoreEgressTcpDataMessage | CoreEgressTcpCloseMessage;
interface ProviderEgressProxyOptions {
providerId: string;
listenHost: string;
listenPort: number;
sendToCore: (message: EgressToCoreMessage) => boolean;
isCoreConnected: () => boolean;
logger: Logger;
}
interface Tunnel {
id: string;
client: Socket;
method: string;
opened: boolean;
pending: Buffer[];
closed: boolean;
}
export interface ProviderEgressProxyHandle {
status: () => Record<string, JsonValue>;
handleCoreMessage: (message: EgressFromCoreMessage) => boolean;
closeAll: (error?: string) => void;
close: () => void;
}
function nowIso(): string {
return new Date().toISOString();
}
function safeConnectionId(): string {
return `egress_${Date.now()}_${Math.random().toString(16).slice(2)}`;
}
function parseConnectTarget(target: string): { host: string; port: number } | null {
const trimmed = target.trim();
if (trimmed.startsWith("[")) {
const end = trimmed.indexOf("]");
if (end <= 1 || trimmed[end + 1] !== ":") return null;
const port = Number(trimmed.slice(end + 2));
return Number.isInteger(port) ? { host: trimmed.slice(1, end), port } : null;
}
const index = trimmed.lastIndexOf(":");
if (index <= 0) return null;
const port = Number(trimmed.slice(index + 1));
return Number.isInteger(port) ? { host: trimmed.slice(0, index), port } : null;
}
function parseHttpProxyRequest(headerText: string): { method: string; target: string; version: string } | null {
const line = headerText.split(/\r?\n/u)[0] ?? "";
const match = /^([A-Z]+)\s+(\S+)\s+(HTTP\/\d(?:\.\d)?)$/u.exec(line);
if (match === null) return null;
return { method: match[1] ?? "", target: match[2] ?? "", version: match[3] ?? "HTTP/1.1" };
}
function httpRequestTarget(rawTarget: string): { host: string; port: number; rewrittenTarget: string } | null {
try {
const url = new URL(rawTarget);
if (url.protocol !== "http:") return null;
return {
host: url.hostname,
port: url.port.length > 0 ? Number(url.port) : 80,
rewrittenTarget: `${url.pathname}${url.search}`,
};
} catch {
return null;
}
}
function rewriteAbsoluteHttpRequest(header: Buffer, rewrittenTarget: string): Buffer {
const text = header.toString("latin1");
const lines = text.split(/\r\n/u);
const first = lines[0] ?? "";
const match = /^([A-Z]+)\s+\S+\s+(HTTP\/\d(?:\.\d)?)$/u.exec(first);
if (match !== null) lines[0] = `${match[1]} ${rewrittenTarget} ${match[2]}`;
return Buffer.from(lines.join("\r\n"), "latin1");
}
function response(status: string, body: string, contentType = "text/plain; charset=utf-8"): string {
return [
`HTTP/1.1 ${status}`,
`Content-Type: ${contentType}`,
"Cache-Control: no-store",
"Connection: close",
`Content-Length: ${Buffer.byteLength(body)}`,
"",
body,
].join("\r\n");
}
function jsonResponse(value: JsonValue): string {
return response("200 OK", `${JSON.stringify(value)}\n`, "application/json; charset=utf-8");
}
function proxyUrlFor(host: string, port: number): string {
return `http://${host}:${port}`;
}
export function startProviderEgressProxy(options: ProviderEgressProxyOptions): ProviderEgressProxyHandle {
const proxyUrl = proxyUrlFor(options.listenHost, options.listenPort);
const tunnels = new Map<string, Tunnel>();
let closed = false;
const send = (message: EgressToCoreMessage): boolean => options.sendToCore(message);
const status = (): Record<string, JsonValue> => ({
enabled: true,
providerId: options.providerId,
connected: options.isCoreConnected(),
proxyUrl,
listenHost: options.listenHost,
listenPort: options.listenPort,
activeTunnels: tunnels.size,
channel: "provider-gateway",
});
const closeTunnel = (id: string, error?: string): void => {
const tunnel = tunnels.get(id);
if (tunnel === undefined) return;
tunnels.delete(id);
tunnel.closed = true;
if (!tunnel.client.destroyed) tunnel.client.destroy();
send({ type: "egress_tcp_close", providerId: options.providerId, connectionId: id, at: nowIso() });
if (error !== undefined) options.logger("warn", "egress_proxy_tunnel_closed", { connectionId: id, error });
};
const handleCoreMessage = (message: EgressFromCoreMessage): boolean => {
const tunnel = tunnels.get(message.connectionId);
if (message.type === "egress_tcp_opened") {
if (tunnel === undefined || tunnel.closed) return true;
tunnel.opened = true;
if (tunnel.method === "CONNECT") {
tunnel.client.write("HTTP/1.1 200 Connection Established\r\nProxy-Agent: UniDesk-ProviderGateway\r\n\r\n");
}
for (const chunk of tunnel.pending.splice(0)) {
send({ type: "egress_tcp_data", providerId: options.providerId, connectionId: tunnel.id, data: chunk.toString("base64"), encoding: "base64", at: nowIso() });
}
return true;
}
if (message.type === "egress_tcp_data") {
if (tunnel === undefined || tunnel.client.destroyed) return true;
tunnel.client.write(Buffer.from(message.data, message.encoding === "base64" ? "base64" : "utf8"));
return true;
}
if (message.type === "egress_tcp_close") {
if (tunnel !== undefined) {
tunnels.delete(message.connectionId);
tunnel.closed = true;
if (!tunnel.client.destroyed) tunnel.client.destroy();
}
if (message.error !== undefined && message.error.length > 0) {
options.logger("warn", "egress_proxy_remote_close", { connectionId: message.connectionId, error: message.error });
}
return true;
}
return false;
};
const server: Server = createServer((client) => {
let header = Buffer.alloc(0);
let settled = false;
const fail = (statusCode: string, text: string): void => {
if (!client.destroyed) client.end(response(statusCode, text));
};
client.on("data", (chunk) => {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
if (settled) return;
header = Buffer.concat([header, buffer]);
const headerEnd = header.indexOf("\r\n\r\n");
if (headerEnd < 0) {
if (header.length > 64 * 1024) {
settled = true;
fail("431 Request Header Fields Too Large", "proxy request header too large\n");
}
return;
}
settled = true;
const head = header.subarray(0, headerEnd + 4);
const rest = header.subarray(headerEnd + 4);
const parsed = parseHttpProxyRequest(head.toString("latin1"));
if (parsed === null) {
fail("400 Bad Request", "invalid proxy request\n");
return;
}
if (parsed.method === "GET" && (parsed.target === "/health" || parsed.target === "/__unidesk/egress-proxy/health")) {
client.end(jsonResponse({ ok: true, service: "provider-gateway-egress-proxy", ...status() }));
return;
}
if (!options.isCoreConnected()) {
fail("503 Service Unavailable", "provider-gateway core channel is not connected\n");
return;
}
const id = safeConnectionId();
let firstPayload: Buffer | null = null;
let target: { host: string; port: number } | null = null;
if (parsed.method === "CONNECT") {
target = parseConnectTarget(parsed.target);
firstPayload = rest.length > 0 ? rest : null;
} else {
const httpTarget = httpRequestTarget(parsed.target);
if (httpTarget !== null) {
target = { host: httpTarget.host, port: httpTarget.port };
firstPayload = Buffer.concat([rewriteAbsoluteHttpRequest(head, httpTarget.rewrittenTarget), rest]);
}
}
if (target === null || target.port <= 0 || target.port > 65_535) {
fail("400 Bad Request", "unsupported proxy target\n");
return;
}
const tunnel: Tunnel = { id, client, method: parsed.method, opened: false, pending: [], closed: false };
tunnels.set(id, tunnel);
client.on("data", (nextChunk) => {
const nextBuffer = Buffer.isBuffer(nextChunk) ? nextChunk : Buffer.from(nextChunk);
if (!tunnel.opened) {
tunnel.pending.push(nextBuffer);
return;
}
send({ type: "egress_tcp_data", providerId: options.providerId, connectionId: id, data: nextBuffer.toString("base64"), encoding: "base64", at: nowIso() });
});
client.on("close", () => closeTunnel(id));
client.on("error", (error) => closeTunnel(id, error.message));
const opened = send({ type: "egress_tcp_open", providerId: options.providerId, connectionId: id, host: target.host, port: target.port, at: nowIso() });
if (!opened) {
tunnels.delete(id);
tunnel.closed = true;
fail("503 Service Unavailable", "provider-gateway core channel is not connected\n");
return;
}
if (firstPayload !== null) tunnel.pending.push(firstPayload);
});
client.on("error", () => undefined);
});
server.listen(options.listenPort, options.listenHost, () => {
options.logger("info", "egress_proxy_listening", status());
});
server.on("error", (error) => {
options.logger("error", "egress_proxy_listen_failed", { proxyUrl, error: error.message });
});
return {
status,
handleCoreMessage,
closeAll: (error?: string) => {
for (const id of Array.from(tunnels.keys())) closeTunnel(id, error);
},
close: () => {
closed = true;
for (const id of Array.from(tunnels.keys())) closeTunnel(id, "egress proxy stopping");
server.close();
},
};
}
@@ -1,6 +1,9 @@
import { spawnSync } from "node:child_process";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import {
type CoreEgressTcpCloseMessage,
type CoreEgressTcpDataMessage,
type CoreEgressTcpOpenedMessage,
type CoreDispatchMessage,
type CoreHostSshCloseMessage,
type CoreHostSshEofMessage,
@@ -20,6 +23,7 @@ import {
parseJsonObject,
} from "../../shared/src/index";
import { createHourlyJsonlWriter, logRetentionBytesForService } from "../../shared/src/rotating-jsonl";
import { startProviderEgressProxy, type ProviderEgressProxyHandle } from "./egress-proxy";
interface RuntimeConfig {
serverUrl: string;
@@ -45,6 +49,9 @@ interface RuntimeConfig {
hostSshKey: string | null;
hostRemoteCwd: string | null;
hostLoginShell: string | null;
egressProxyEnabled: boolean;
egressProxyListenHost: string;
egressProxyPort: number;
logFile: string;
}
@@ -114,6 +121,7 @@ interface DockerContainerInspectDetails {
const hostSshSessions = new Map<string, HostSshSession>();
const microserviceHttpCache = new Map<string, MicroserviceHttpCacheEntry>();
const microserviceHttpInFlight = new Map<string, Promise<JsonValue>>();
let providerEgressProxy: ProviderEgressProxyHandle | null = null;
const gatewayMetadata = readGatewayMetadata();
const defaultMasterServer = "http://74.48.78.17/";
const defaultProviderToken = "unidesk-dev-token-change-me";
@@ -196,6 +204,15 @@ function readOptionalNumberEnv(name: string, ...aliases: string[]): number | nul
return parsed;
}
function readBooleanEnv(name: string, fallback: boolean, ...aliases: string[]): boolean {
const raw = readEnv(name, ...aliases);
if (raw === null || raw.trim().length === 0) return fallback;
const value = raw.trim().toLowerCase();
if (value === "1" || value === "true" || value === "yes" || value === "on") return true;
if (value === "0" || value === "false" || value === "no" || value === "off") return false;
return fallback;
}
function providerServerUrlFromMaster(rawMaster: string): string {
const raw = rawMaster.trim() || defaultMasterServer;
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//iu.test(raw) ? raw : `http://${raw}`;
@@ -462,6 +479,9 @@ function readConfig(): RuntimeConfig {
hostSshKey: readOptionalStringEnv("HOST_SSH_KEY") ?? (mountedHostSshKey ? defaultHostSshKey : null),
hostRemoteCwd: readOptionalStringEnv("HOST_REMOTE_CWD") ?? defaultHostRemoteCwd(hostSshUser),
hostLoginShell: readOptionalStringEnv("HOST_LOGIN_SHELL") ?? (hostSshUser === null ? null : "/bin/bash"),
egressProxyEnabled: readBooleanEnv("PROVIDER_EGRESS_PROXY_ENABLED", true, "UNIDESK_PROVIDER_EGRESS_PROXY_ENABLED"),
egressProxyListenHost: readOptionalStringEnv("PROVIDER_EGRESS_PROXY_LISTEN_HOST", "UNIDESK_PROVIDER_EGRESS_PROXY_LISTEN_HOST") ?? "0.0.0.0",
egressProxyPort: readNumberEnv("PROVIDER_EGRESS_PROXY_PORT", 18789, "UNIDESK_PROVIDER_EGRESS_PROXY_PORT"),
logFile: readOptionalStringEnv("LOG_FILE") ?? `/var/log/unidesk/provider-gateway-${providerId}.jsonl`,
};
}
@@ -497,6 +517,7 @@ function withToken(rawUrl: string, token: string): string {
function currentLabels(): ProviderLabels {
const hostSshConfigured = isHostSshConfigured();
const containerGuard = refreshSelfContainerGuard();
const egressProxyStatus = providerEgressProxy?.status() ?? null;
return {
...config.labels,
dockerSocketPresent: existsSync(config.dockerSocketPath),
@@ -509,6 +530,10 @@ function currentLabels(): ProviderLabels {
providerGatewayStartedAt: startedAt.toISOString(),
providerGatewayUpgradePolicy: "always-enabled",
providerGatewayMicroserviceHttpCache: true,
providerGatewayEgressProxy: config.egressProxyEnabled,
providerGatewayEgressProxyPort: config.egressProxyPort,
providerGatewayEgressProxyConnected: egressProxyStatus === null ? false : egressProxyStatus.connected === true,
providerGatewayEgressProxyActiveTunnels: egressProxyStatus === null ? 0 : egressProxyStatus.activeTunnels ?? 0,
providerGatewayDockerRestartGuard: true,
providerGatewayContainerId: containerGuard.containerId,
providerGatewayContainerName: containerGuard.containerName,
@@ -529,9 +554,16 @@ function sendJson(value: unknown): void {
socket.send(JSON.stringify(value));
}
function sendJsonOk(value: unknown): boolean {
if (!socket || socket.readyState !== WebSocket.OPEN) return false;
socket.send(JSON.stringify(value));
return true;
}
function sendRegister(): void {
const capabilities = ["heartbeat", "system.status", "docker.status", "docker.ps", "provider.upgrade", "microservice.http", "microservice.http.cache", "echo"];
if (isHostSshConfigured()) capabilities.push("host.ssh");
if (config.egressProxyEnabled) capabilities.push("network.egress-proxy");
sendJson({
type: "register",
providerId: config.providerId,
@@ -2043,6 +2075,14 @@ function handleMessage(raw: MessageEvent<string>): void {
closeHostSshSession(parsed as CoreHostSshCloseMessage);
return;
}
if (
parsed.type === "egress_tcp_opened" ||
parsed.type === "egress_tcp_data" ||
parsed.type === "egress_tcp_close"
) {
providerEgressProxy?.handleCoreMessage(parsed as CoreEgressTcpOpenedMessage | CoreEgressTcpDataMessage | CoreEgressTcpCloseMessage);
return;
}
logger("debug", "core_message", parsed as JsonValue);
} catch (error) {
logger("error", "core_message_parse_failed", { error: String(error) });
@@ -2135,6 +2175,7 @@ function connect(): void {
socket.addEventListener("close", (event) => {
logger("warn", "connect_close", { code: event.code, reason: event.reason });
clearConnectionTimers();
providerEgressProxy?.closeAll("provider-gateway core socket closed");
scheduleReconnect();
});
socket.addEventListener("error", () => {
@@ -2153,8 +2194,20 @@ process.on("SIGTERM", () => {
session.proc.kill("SIGTERM");
}
hostSshSessions.clear();
providerEgressProxy?.close();
socket?.close(1000, "provider shutdown");
process.exit(0);
});
if (config.egressProxyEnabled) {
providerEgressProxy = startProviderEgressProxy({
providerId: config.providerId,
listenHost: config.egressProxyListenHost,
listenPort: config.egressProxyPort,
isCoreConnected: () => socket?.readyState === WebSocket.OPEN,
sendToCore: (message) => sendJsonOk(message),
logger,
});
}
connect();
+54 -2
View File
@@ -219,6 +219,49 @@ export interface ProviderHostSshErrorMessage {
at: string;
}
export interface ProviderEgressTcpOpenMessage {
type: "egress_tcp_open";
providerId: string;
connectionId: string;
host: string;
port: number;
at: string;
}
export interface ProviderEgressTcpDataMessage {
type: "egress_tcp_data";
providerId: string;
connectionId: string;
data: string;
encoding: "base64";
at: string;
}
export interface ProviderEgressTcpCloseMessage {
type: "egress_tcp_close";
providerId: string;
connectionId: string;
at: string;
}
export interface CoreEgressTcpOpenedMessage {
type: "egress_tcp_opened";
connectionId: string;
}
export interface CoreEgressTcpDataMessage {
type: "egress_tcp_data";
connectionId: string;
data: string;
encoding: "base64";
}
export interface CoreEgressTcpCloseMessage {
type: "egress_tcp_close";
connectionId: string;
error?: string;
}
export type ProviderToCoreMessage =
| ProviderRegisterMessage
| ProviderHeartbeatMessage
@@ -228,7 +271,10 @@ export type ProviderToCoreMessage =
| ProviderHostSshOpenedMessage
| ProviderHostSshDataMessage
| ProviderHostSshExitMessage
| ProviderHostSshErrorMessage;
| ProviderHostSshErrorMessage
| ProviderEgressTcpOpenMessage
| ProviderEgressTcpDataMessage
| ProviderEgressTcpCloseMessage;
export type CoreToProviderMessage =
| CoreDispatchMessage
@@ -237,6 +283,9 @@ export type CoreToProviderMessage =
| CoreHostSshResizeMessage
| CoreHostSshCloseMessage
| CoreHostSshEofMessage
| CoreEgressTcpOpenedMessage
| CoreEgressTcpDataMessage
| CoreEgressTcpCloseMessage
| CoreAcknowledgeMessage;
export interface ApiNode {
@@ -317,7 +366,10 @@ export function isProviderToCoreMessage(value: unknown): value is ProviderToCore
msg.type === "host_ssh_opened" ||
msg.type === "host_ssh_data" ||
msg.type === "host_ssh_exit" ||
msg.type === "host_ssh_error"
msg.type === "host_ssh_error" ||
msg.type === "egress_tcp_open" ||
msg.type === "egress_tcp_data" ||
msg.type === "egress_tcp_close"
) &&
typeof msg.providerId === "string" &&
msg.providerId.length > 0