feat: route code queue egress through provider gateway
This commit is contained in:
@@ -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,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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user