feat: add provider websocket http data plane
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import type { JsonValue } from "../../shared/src/index";
|
||||
import type {
|
||||
EgressTcpConnection,
|
||||
HttpTunnelWaiter,
|
||||
LoggerFn,
|
||||
MicroserviceAvailabilityEntry,
|
||||
MicroserviceProxyCacheEntry,
|
||||
@@ -22,6 +23,7 @@ export const ctx = {
|
||||
activeProviders: new Map<string, ProviderSocket>(),
|
||||
activeSshClients: new Map<string, ProviderSocket>(),
|
||||
activeEgressTcpConnections: new Map<string, EgressTcpConnection>(),
|
||||
httpTunnelWaiters: new Map<string, HttpTunnelWaiter>(),
|
||||
taskTerminalWaiters: new Map<string, Set<TaskTerminalWaiter>>(),
|
||||
microserviceProxyCache: new Map<string, MicroserviceProxyCacheEntry>(),
|
||||
microserviceProxyRefreshes: new Map<string, Promise<void>>(),
|
||||
|
||||
@@ -172,6 +172,12 @@ const providerServer = Bun.serve<WsData>({
|
||||
logger("warn", "provider_socket_close", { providerId: providerId ?? null });
|
||||
if (providerId !== undefined) {
|
||||
closeEgressTcpConnectionsForProvider(providerId);
|
||||
for (const [requestId, waiter] of ctx.httpTunnelWaiters) {
|
||||
if (requestId.startsWith(`${providerId}:`)) {
|
||||
ctx.httpTunnelWaiters.delete(requestId);
|
||||
waiter(null);
|
||||
}
|
||||
}
|
||||
if (ctx.activeProviders.get(providerId) !== ws) {
|
||||
logger("info", "provider_socket_close_ignored_replaced", { providerId });
|
||||
return;
|
||||
|
||||
@@ -202,11 +202,15 @@ function responseFromMicroserviceResult(task: RawTaskRow | null): Response {
|
||||
if (task === null) return jsonResponse({ ok: false, error: "microservice proxy task missing" }, 502);
|
||||
if (task.status !== "succeeded") return jsonResponse({ ok: false, error: "microservice proxy task failed", task }, 502);
|
||||
const result = dockerStatusRecord(task.result);
|
||||
return responseFromProviderMicroserviceResult(result, "provider-task");
|
||||
}
|
||||
|
||||
function responseFromProviderMicroserviceResult(result: Record<string, unknown>, proxyMode: string): Response {
|
||||
const status = Number(result.status);
|
||||
const contentType = typeof result.contentType === "string" ? result.contentType : "application/json; charset=utf-8";
|
||||
const bodyText = typeof result.bodyText === "string" ? result.bodyText : "";
|
||||
if (!Number.isInteger(status) || status < 100 || status > 599) {
|
||||
return jsonResponse({ ok: false, error: "microservice proxy returned invalid upstream status", task }, 502);
|
||||
return jsonResponse({ ok: false, error: "microservice proxy returned invalid upstream status", result }, 502);
|
||||
}
|
||||
if (result.truncated === true && contentTypeIsJson(contentType)) {
|
||||
try {
|
||||
@@ -215,8 +219,6 @@ function responseFromMicroserviceResult(task: RawTaskRow | null): Response {
|
||||
return jsonResponse({
|
||||
ok: false,
|
||||
error: "microservice proxy response was truncated before a JSON boundary",
|
||||
providerId: task.provider_id,
|
||||
command: task.command,
|
||||
upstreamStatus: status,
|
||||
upstreamBodyBytes: result.upstreamBodyBytes ?? null,
|
||||
returnedBodyBytes: result.returnedBodyBytes ?? bodyText.length,
|
||||
@@ -229,6 +231,8 @@ function responseFromMicroserviceResult(task: RawTaskRow | null): Response {
|
||||
status,
|
||||
headers: {
|
||||
"content-type": contentType,
|
||||
"x-unidesk-proxy-mode": proxyMode,
|
||||
"x-unidesk-upstream-proxy-mode": typeof result.proxyMode === "string" ? result.proxyMode : "",
|
||||
"x-unidesk-response-truncated": result.truncated === true ? "true" : "false",
|
||||
},
|
||||
});
|
||||
@@ -607,6 +611,84 @@ async function k3sctlAdapterMicroserviceResponse(
|
||||
return fetchMicroserviceUpstreamResponse(adapter, method, adapterTargetPath, proxyOptions, requestHeaders, bodyText, abortSignal);
|
||||
}
|
||||
|
||||
function providerHttpTunnelRequestId(providerId: string): string {
|
||||
return `${providerId}:http_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
async function waitForProviderHttpTunnelResponse(
|
||||
providerId: string,
|
||||
requestId: string,
|
||||
timeoutMs: number,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<{ providerId: string; requestId: string; ok: boolean; result: JsonValue } | null> {
|
||||
return await new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let abortHandler: (() => void) | null = null;
|
||||
const timer = setTimeout(() => settle(null), Math.max(1, timeoutMs));
|
||||
const settle = (message: { providerId: string; requestId: string; ok: boolean; result: JsonValue } | null): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (abortHandler !== null) abortSignal?.removeEventListener("abort", abortHandler);
|
||||
ctx.httpTunnelWaiters.delete(requestId);
|
||||
resolve(message);
|
||||
};
|
||||
abortHandler = () => settle(null);
|
||||
if (abortSignal !== undefined) {
|
||||
if (abortSignal.aborted) {
|
||||
settle(null);
|
||||
return;
|
||||
}
|
||||
abortSignal.addEventListener("abort", abortHandler, { once: true });
|
||||
}
|
||||
ctx.httpTunnelWaiters.set(requestId, (message) => {
|
||||
if (message !== null && message.providerId !== providerId) {
|
||||
logger("warn", "http_tunnel_provider_mismatch", { requestId, expectedProviderId: providerId, actualProviderId: message.providerId });
|
||||
settle(null);
|
||||
return;
|
||||
}
|
||||
settle(message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function providerHttpTunnelMicroserviceResponse(
|
||||
service: MicroserviceConfig,
|
||||
method: string,
|
||||
targetPath: string,
|
||||
proxyOptions: { query: string; jsonArrayLimits: Record<string, JsonValue> },
|
||||
requestHeaders: Record<string, JsonValue>,
|
||||
bodyText: string,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<Response> {
|
||||
const socket = ctx.activeProviders.get(service.providerId);
|
||||
if (socket === undefined) return jsonResponse({ ok: false, error: `provider is offline: ${service.providerId}` }, 503);
|
||||
const requestId = providerHttpTunnelRequestId(service.providerId);
|
||||
const timeoutMs = service.backend.timeoutMs + 3000;
|
||||
const waiter = waitForProviderHttpTunnelResponse(service.providerId, requestId, timeoutMs, abortSignal);
|
||||
socket.send(JSON.stringify({
|
||||
type: "http_tunnel_request",
|
||||
requestId,
|
||||
payload: {
|
||||
source: "microservice-frontend-proxy",
|
||||
serviceId: service.id,
|
||||
method,
|
||||
targetBaseUrl: service.backend.nodeBaseUrl,
|
||||
path: targetPath,
|
||||
query: proxyOptions.query,
|
||||
requestHeaders,
|
||||
bodyText,
|
||||
jsonArrayLimits: proxyOptions.jsonArrayLimits,
|
||||
timeoutMs: service.backend.timeoutMs,
|
||||
cacheTtlMs: providerMicroserviceCacheTtlMs(service.id, targetPath),
|
||||
},
|
||||
}));
|
||||
const message = await waiter;
|
||||
if (message === null) return jsonResponse({ ok: false, error: "provider HTTP tunnel timed out or disconnected", providerId: service.providerId, requestId }, 504);
|
||||
if (!message.ok) return jsonResponse({ ok: false, error: "provider HTTP tunnel failed", providerId: service.providerId, requestId, result: message.result }, 502);
|
||||
return responseFromProviderMicroserviceResult(dockerStatusRecord(message.result), "provider-ws-http-tunnel");
|
||||
}
|
||||
|
||||
async function fetchMicroserviceUpstreamResponse(
|
||||
service: MicroserviceConfig,
|
||||
method: string,
|
||||
@@ -625,6 +707,9 @@ async function fetchMicroserviceUpstreamResponse(
|
||||
if (!(await providerSupports(service.providerId, "microservice.http"))) {
|
||||
return jsonResponse({ ok: false, error: `provider does not declare microservice.http capability: ${service.providerId}` }, 409);
|
||||
}
|
||||
if (await providerSupports(service.providerId, "microservice.http.tunnel")) {
|
||||
return providerHttpTunnelMicroserviceResponse(service, method, targetPath, proxyOptions, requestHeaders, bodyText, abortSignal);
|
||||
}
|
||||
const { taskId, providerOnline } = await createAndSendTask(service.providerId, "microservice.http", {
|
||||
source: "microservice-frontend-proxy",
|
||||
serviceId: service.id,
|
||||
|
||||
@@ -81,6 +81,22 @@ export async function handleProviderMessage(ws: ProviderSocket, raw: string | Bu
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "http_tunnel_response") {
|
||||
const waiter = ctx.httpTunnelWaiters.get(message.requestId);
|
||||
if (waiter === undefined) {
|
||||
logger("warn", "http_tunnel_response_without_waiter", { providerId: message.providerId, requestId: message.requestId });
|
||||
return;
|
||||
}
|
||||
ctx.httpTunnelWaiters.delete(message.requestId);
|
||||
waiter({
|
||||
providerId: message.providerId,
|
||||
requestId: message.requestId,
|
||||
ok: message.ok,
|
||||
result: message.result,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "egress_tcp_open") {
|
||||
handleEgressTcpOpen(ws, message);
|
||||
return;
|
||||
|
||||
@@ -188,4 +188,11 @@ export interface MicroserviceAvailabilityEntry {
|
||||
probe: Record<string, JsonValue>;
|
||||
}
|
||||
|
||||
export type HttpTunnelWaiter = (message: {
|
||||
providerId: string;
|
||||
requestId: string;
|
||||
ok: boolean;
|
||||
result: JsonValue;
|
||||
} | null) => void;
|
||||
|
||||
export type LoggerFn = (level: "debug" | "info" | "warn" | "error", message: string, data?: JsonValue) => void;
|
||||
|
||||
@@ -46,7 +46,11 @@ RUN (command -v codex >/dev/null 2>&1 && command -v opencode >/dev/null 2>&1 &&
|
||||
WORKDIR /app/src/components/microservices/code-queue
|
||||
COPY src/components/microservices/code-queue/package.json ./package.json
|
||||
RUN test -d node_modules/typescript || bun install
|
||||
WORKDIR /app
|
||||
COPY package.json /app/package.json
|
||||
RUN bun install
|
||||
COPY src/components/shared /app/src/components/shared
|
||||
WORKDIR /app/src/components/microservices/code-queue
|
||||
COPY src/components/microservices/code-queue/tsconfig.json ./tsconfig.json
|
||||
COPY src/components/microservices/code-queue/src ./src
|
||||
|
||||
|
||||
@@ -31,6 +31,11 @@ services:
|
||||
K3SCTL_KUBE_API_LOCAL_PORT: "${K3SCTL_KUBE_API_LOCAL_PORT:-6443}"
|
||||
K3SCTL_KUBE_API_REMOTE_HOST: "${K3SCTL_KUBE_API_REMOTE_HOST:-127.0.0.1}"
|
||||
K3SCTL_KUBE_API_REMOTE_PORT: "${K3SCTL_KUBE_API_REMOTE_PORT:-6443}"
|
||||
K3SCTL_NATIVE_SERVICE_PROXY_ENABLED: "${K3SCTL_NATIVE_SERVICE_PROXY_ENABLED:-true}"
|
||||
K3SCTL_NATIVE_SERVICE_PROBE_TIMEOUT_MS: "${K3SCTL_NATIVE_SERVICE_PROBE_TIMEOUT_MS:-1200}"
|
||||
K3SCTL_NATIVE_SERVICE_FAILURE_COOLDOWN_MS: "${K3SCTL_NATIVE_SERVICE_FAILURE_COOLDOWN_MS:-10000}"
|
||||
K3SCTL_NATIVE_SERVICE_URL_CODE_QUEUE: "${K3SCTL_NATIVE_SERVICE_URL_CODE_QUEUE:-}"
|
||||
K3SCTL_NATIVE_SERVICE_URL_MDTODO: "${K3SCTL_NATIVE_SERVICE_URL_MDTODO:-}"
|
||||
K3SCTL_MANIFEST_PATHS: "${K3SCTL_MANIFEST_PATHS:-k3s/code-queue.k3s.json,k3s/mdtodo.k3s.json}"
|
||||
K3SCTL_SERVICES_JSON: "${K3SCTL_SERVICES_JSON:-[]}"
|
||||
UNIDESK_LOG_RETENTION_BYTES: "${UNIDESK_LOG_RETENTION_BYTES:-512MiB}"
|
||||
|
||||
@@ -44,6 +44,9 @@ interface RuntimeConfig {
|
||||
kubeApiProxyEnabled: boolean;
|
||||
kubeconfigPath: string;
|
||||
kubeApiConnectHost: string;
|
||||
nativeServiceProxyEnabled: boolean;
|
||||
nativeServiceProbeTimeoutMs: number;
|
||||
nativeServiceFailureCooldownMs: number;
|
||||
requestTimeoutMs: number;
|
||||
healthTimeoutMs: number;
|
||||
services: ManagedService[];
|
||||
@@ -69,6 +72,7 @@ const logWriter = config.logFile
|
||||
})
|
||||
: null;
|
||||
const kubeClient = loadKubeApiClient();
|
||||
const nativeServiceFailures = new Map<string, number>();
|
||||
logWriter?.prune();
|
||||
|
||||
function envString(name: string, fallback: string): string {
|
||||
@@ -92,6 +96,11 @@ function envBool(name: string, fallback: boolean): boolean {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function envOptionalString(name: string): string | null {
|
||||
const value = process.env[name];
|
||||
return value === undefined || value.trim().length === 0 ? null : value.trim();
|
||||
}
|
||||
|
||||
function asRecord(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
|
||||
return value as Record<string, unknown>;
|
||||
@@ -259,6 +268,9 @@ function readConfig(): RuntimeConfig {
|
||||
kubeApiProxyEnabled: envBool("K3SCTL_KUBE_API_PROXY_ENABLED", true),
|
||||
kubeconfigPath: envString("K3SCTL_KUBECONFIG_PATH", "/var/lib/unidesk/k3s/kubeconfig"),
|
||||
kubeApiConnectHost: envString("K3SCTL_KUBE_API_CONNECT_HOST", "host.docker.internal"),
|
||||
nativeServiceProxyEnabled: envBool("K3SCTL_NATIVE_SERVICE_PROXY_ENABLED", true),
|
||||
nativeServiceProbeTimeoutMs: Math.max(250, Math.min(5000, envNumber("K3SCTL_NATIVE_SERVICE_PROBE_TIMEOUT_MS", 1200))),
|
||||
nativeServiceFailureCooldownMs: Math.max(1000, Math.min(60_000, envNumber("K3SCTL_NATIVE_SERVICE_FAILURE_COOLDOWN_MS", 10_000))),
|
||||
requestTimeoutMs: Math.max(1000, Math.min(120_000, envNumber("K3SCTL_REQUEST_TIMEOUT_MS", 30_000))),
|
||||
healthTimeoutMs: Math.max(500, Math.min(30_000, envNumber("K3SCTL_HEALTH_TIMEOUT_MS", 2500))),
|
||||
services: mergeServices([...manifestServices, ...inlineServices]),
|
||||
@@ -399,6 +411,37 @@ function endpointProxyApiPath(service: ManagedService, endpoint: ManagedEndpoint
|
||||
return `/api/v1/namespaces/${encodeURIComponent(namespace)}/services/${encodeURIComponent(serviceRef)}/proxy${safeTargetPath}`;
|
||||
}
|
||||
|
||||
function nativeServiceFailureKey(service: ManagedService): string {
|
||||
return `${service.namespace}/${service.id}`;
|
||||
}
|
||||
|
||||
function nativeServiceUrl(service: ManagedService, targetPath: string, query = ""): URL {
|
||||
const serviceName = routeString(service, "serviceName", service.id);
|
||||
const servicePort = routeNumber(service, "servicePort", 80);
|
||||
const safeTargetPath = targetPath.startsWith("/") ? targetPath : `/${targetPath}`;
|
||||
const override = envOptionalString(`K3SCTL_NATIVE_SERVICE_URL_${service.id.toUpperCase().replace(/[^A-Z0-9]/gu, "_")}`);
|
||||
const base = override ?? `http://${serviceName}.${service.namespace}.svc.cluster.local:${servicePort}`;
|
||||
const url = new URL(safeTargetPath, base.replace(/\/+$/u, "/"));
|
||||
url.search = query;
|
||||
return url;
|
||||
}
|
||||
|
||||
function nativeServiceProxyUsable(service: ManagedService): boolean {
|
||||
if (!config.nativeServiceProxyEnabled) return false;
|
||||
const failedAt = nativeServiceFailures.get(nativeServiceFailureKey(service));
|
||||
return failedAt === undefined || Date.now() - failedAt >= config.nativeServiceFailureCooldownMs;
|
||||
}
|
||||
|
||||
function rememberNativeServiceFailure(service: ManagedService, error: unknown): void {
|
||||
nativeServiceFailures.set(nativeServiceFailureKey(service), Date.now());
|
||||
log("warn", "native_service_proxy_failed", {
|
||||
serviceId: service.id,
|
||||
namespace: service.namespace,
|
||||
cooldownMs: config.nativeServiceFailureCooldownMs,
|
||||
error: errorToJson(error),
|
||||
});
|
||||
}
|
||||
|
||||
function kubernetesEndpointServiceRef(service: ManagedService, endpoint: ManagedEndpoint): { namespace: string; serviceRef: string } {
|
||||
const base = new URL(endpoint.baseUrl);
|
||||
if (base.protocol !== "kubernetes:") throw new Error(`endpoint ${endpoint.id} must use kubernetes:// baseUrl`);
|
||||
@@ -460,6 +503,48 @@ async function kubeApiServiceProxyResponse(
|
||||
return kubeApiProxyResponse(service, req, serviceProxyApiPath(service, targetPath), query, timeoutMs);
|
||||
}
|
||||
|
||||
async function nativeServiceProxyResponse(
|
||||
service: ManagedService,
|
||||
req: Request,
|
||||
targetPath: string,
|
||||
query: string,
|
||||
timeoutMs: number,
|
||||
): Promise<Response | null> {
|
||||
if (!nativeServiceProxyUsable(service)) return null;
|
||||
const upstreamUrl = nativeServiceUrl(service, targetPath, query);
|
||||
const headers = forwardHeaders(req);
|
||||
const bodyText = req.method === "GET" || req.method === "HEAD" ? "" : await req.text();
|
||||
const controller = new AbortController();
|
||||
const nativeTimeoutMs = Math.min(timeoutMs, config.nativeServiceProbeTimeoutMs);
|
||||
const timer = setTimeout(() => controller.abort(), nativeTimeoutMs);
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const upstream = await fetch(upstreamUrl, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: bodyText.length > 0 ? bodyText : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const body = await boundedText(upstream, 8 * 1024 * 1024);
|
||||
return new Response(body.text, {
|
||||
status: upstream.status,
|
||||
headers: {
|
||||
"content-type": upstream.headers.get("content-type") ?? "application/octet-stream",
|
||||
"x-unidesk-proxy-mode": "kubernetes-native-service",
|
||||
"x-unidesk-k3s-service": service.id,
|
||||
"x-unidesk-k3s-service-url": upstreamUrl.origin,
|
||||
"x-unidesk-upstream-duration-ms": String(Date.now() - startedAt),
|
||||
"x-unidesk-response-truncated": body.truncated ? "true" : "false",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
rememberNativeServiceFailure(service, error);
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function kubeApiEndpointProxyResponse(
|
||||
service: ManagedService,
|
||||
endpoint: ManagedEndpoint,
|
||||
@@ -574,15 +659,13 @@ async function probeKubernetesServiceActive(service: ManagedService): Promise<Js
|
||||
async function probeKubernetesEndpoint(service: ManagedService, endpoint: ManagedEndpoint, active = false): Promise<JsonRecord> {
|
||||
if (!active && endpoint.healthMode === "pod-ready") return await probeKubernetesPodReady(service, endpoint);
|
||||
const checkedAt = new Date().toISOString();
|
||||
const response = active
|
||||
? await kubeApiServiceProxyResponse(
|
||||
service,
|
||||
new Request("http://k3sctl-adapter.local/health", { method: "GET", headers: { accept: "application/json" } }),
|
||||
endpoint.healthPath,
|
||||
"",
|
||||
config.healthTimeoutMs,
|
||||
)
|
||||
: await kubeApiEndpointProxyResponse(
|
||||
let response: Response;
|
||||
if (active) {
|
||||
const request = new Request("http://k3sctl-adapter.local/health", { method: "GET", headers: { accept: "application/json" } });
|
||||
response = await nativeServiceProxyResponse(service, request.clone(), endpoint.healthPath, "", config.healthTimeoutMs)
|
||||
?? await kubeApiServiceProxyResponse(service, request, endpoint.healthPath, "", config.healthTimeoutMs);
|
||||
} else {
|
||||
response = await kubeApiEndpointProxyResponse(
|
||||
service,
|
||||
endpoint,
|
||||
new Request("http://k3sctl-adapter.local/health", { method: "GET", headers: { accept: "application/json" } }),
|
||||
@@ -590,6 +673,7 @@ async function probeKubernetesEndpoint(service: ManagedService, endpoint: Manage
|
||||
"",
|
||||
config.healthTimeoutMs,
|
||||
);
|
||||
}
|
||||
const contentType = response.headers.get("content-type") ?? "application/octet-stream";
|
||||
const bodyText = await response.text();
|
||||
let body: JsonValue = bodyText.slice(0, 2000);
|
||||
@@ -605,7 +689,7 @@ async function probeKubernetesEndpoint(service: ManagedService, endpoint: Manage
|
||||
baseUrl: endpoint.baseUrl,
|
||||
healthPath: endpoint.healthPath,
|
||||
healthMode: endpoint.healthMode,
|
||||
proxyMode: "kubernetes-api-service-proxy",
|
||||
proxyMode: response.headers.get("x-unidesk-proxy-mode") ?? "kubernetes-api-service-proxy",
|
||||
route: service.route,
|
||||
healthy: response.ok,
|
||||
status: response.ok ? "healthy" : "unhealthy",
|
||||
@@ -761,7 +845,19 @@ async function controlPlaneSnapshot(): Promise<JsonRecord> {
|
||||
manifestPaths: config.manifestPaths,
|
||||
managedServicesHealthy,
|
||||
noFallback: true,
|
||||
runtimePath: "frontend -> backend-core -> k3sctl-adapter -> kubernetes api service proxy -> k3s service",
|
||||
runtimePath: config.nativeServiceProxyEnabled
|
||||
? "frontend -> backend-core -> provider websocket HTTP tunnel -> k3sctl-adapter -> kubernetes native service/DNS -> k3s service"
|
||||
: "frontend -> backend-core -> k3sctl-adapter -> kubernetes api service proxy -> k3s service",
|
||||
nativeServiceProxy: {
|
||||
enabled: config.nativeServiceProxyEnabled,
|
||||
mode: "kubernetes-native-service",
|
||||
failureCooldownMs: config.nativeServiceFailureCooldownMs,
|
||||
failedServices: Array.from(nativeServiceFailures.entries()).map(([key, failedAt]) => ({
|
||||
key,
|
||||
failedAt: new Date(failedAt).toISOString(),
|
||||
retryAfterMs: Math.max(0, config.nativeServiceFailureCooldownMs - (Date.now() - failedAt)),
|
||||
})),
|
||||
},
|
||||
kubeApiProxy: {
|
||||
enabled: config.kubeApiProxyEnabled,
|
||||
configured: kubeClient !== null,
|
||||
@@ -786,6 +882,8 @@ function forwardHeaders(request: Request): Headers {
|
||||
|
||||
async function proxyToService(service: ManagedService, req: Request, targetPath: string, query: string): Promise<Response> {
|
||||
if (isKubernetesServiceRoute(service)) {
|
||||
const native = await nativeServiceProxyResponse(service, req.clone(), targetPath, query, config.requestTimeoutMs);
|
||||
if (native !== null) return native;
|
||||
return kubeApiServiceProxyResponse(service, req, targetPath, query, config.requestTimeoutMs);
|
||||
}
|
||||
log("error", "k3sctl_route_not_kubernetes_service", { serviceId: service.id, route: service.route, noFallback: true });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@unidesk/provider-gateway",
|
||||
"version": "0.2.19",
|
||||
"version": "0.2.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type CoreEgressTcpDataMessage,
|
||||
type CoreEgressTcpOpenedMessage,
|
||||
type CoreDispatchMessage,
|
||||
type CoreHttpTunnelRequestMessage,
|
||||
type CoreHostSshCloseMessage,
|
||||
type CoreHostSshEofMessage,
|
||||
type CoreHostSshInputMessage,
|
||||
@@ -561,7 +562,7 @@ function sendJsonOk(value: unknown): boolean {
|
||||
}
|
||||
|
||||
function sendRegister(): void {
|
||||
const capabilities = ["heartbeat", "system.status", "docker.status", "docker.ps", "provider.upgrade", "microservice.http", "microservice.http.cache", "echo"];
|
||||
const capabilities = ["heartbeat", "system.status", "docker.status", "docker.ps", "provider.upgrade", "microservice.http", "microservice.http.cache", "microservice.http.tunnel", "echo"];
|
||||
if (isHostSshConfigured()) capabilities.push("host.ssh");
|
||||
if (config.egressProxyEnabled) capabilities.push("network.egress-proxy");
|
||||
sendJson({
|
||||
@@ -1989,6 +1990,7 @@ async function runMicroserviceHttp(payload: Record<string, JsonValue>): Promise<
|
||||
truncated: bounded.truncated,
|
||||
transform: transformed.transform,
|
||||
upstreamDurationMs: Date.now() - requestStartedAt,
|
||||
proxyMode: "provider-gateway-http-fetch",
|
||||
};
|
||||
})();
|
||||
if (cacheable) microserviceHttpInFlight.set(cacheKey, requestPromise);
|
||||
@@ -2053,6 +2055,38 @@ async function handleDispatch(message: CoreDispatchMessage): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleHttpTunnelRequest(message: CoreHttpTunnelRequestMessage): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const result = await runMicroserviceHttp(message.payload);
|
||||
sendJson({
|
||||
type: "http_tunnel_response",
|
||||
providerId: config.providerId,
|
||||
requestId: message.requestId,
|
||||
ok: (result as { ok?: unknown }).ok === true,
|
||||
result,
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
logger("debug", "http_tunnel_completed", {
|
||||
requestId: message.requestId,
|
||||
serviceId: typeof message.payload.serviceId === "string" ? message.payload.serviceId : "",
|
||||
durationMs: Date.now() - startedAt,
|
||||
ok: (result as { ok?: unknown }).ok === true,
|
||||
});
|
||||
} catch (error) {
|
||||
const text = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
||||
logger("error", "http_tunnel_failed", { requestId: message.requestId, error: text });
|
||||
sendJson({
|
||||
type: "http_tunnel_response",
|
||||
providerId: config.providerId,
|
||||
requestId: message.requestId,
|
||||
ok: false,
|
||||
result: { ok: false, error: text },
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessage(raw: MessageEvent<string>): void {
|
||||
try {
|
||||
const parsed = JSON.parse(raw.data) as { type?: unknown };
|
||||
@@ -2060,6 +2094,10 @@ function handleMessage(raw: MessageEvent<string>): void {
|
||||
handleDispatch(parsed as CoreDispatchMessage).catch((error) => logger("error", "dispatch_handler_failed", { error: String(error) }));
|
||||
return;
|
||||
}
|
||||
if (parsed.type === "http_tunnel_request") {
|
||||
handleHttpTunnelRequest(parsed as CoreHttpTunnelRequestMessage).catch((error) => logger("error", "http_tunnel_handler_failed", { error: String(error) }));
|
||||
return;
|
||||
}
|
||||
if (parsed.type === "host_ssh_open") {
|
||||
startHostSshSession(parsed as CoreHostSshOpenMessage);
|
||||
return;
|
||||
|
||||
@@ -244,6 +244,21 @@ export interface ProviderEgressTcpCloseMessage {
|
||||
at: string;
|
||||
}
|
||||
|
||||
export interface CoreHttpTunnelRequestMessage {
|
||||
type: "http_tunnel_request";
|
||||
requestId: string;
|
||||
payload: Record<string, JsonValue>;
|
||||
}
|
||||
|
||||
export interface ProviderHttpTunnelResponseMessage {
|
||||
type: "http_tunnel_response";
|
||||
providerId: string;
|
||||
requestId: string;
|
||||
ok: boolean;
|
||||
result: JsonValue;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export interface CoreEgressTcpOpenedMessage {
|
||||
type: "egress_tcp_opened";
|
||||
connectionId: string;
|
||||
@@ -274,10 +289,12 @@ export type ProviderToCoreMessage =
|
||||
| ProviderHostSshErrorMessage
|
||||
| ProviderEgressTcpOpenMessage
|
||||
| ProviderEgressTcpDataMessage
|
||||
| ProviderEgressTcpCloseMessage;
|
||||
| ProviderEgressTcpCloseMessage
|
||||
| ProviderHttpTunnelResponseMessage;
|
||||
|
||||
export type CoreToProviderMessage =
|
||||
| CoreDispatchMessage
|
||||
| CoreHttpTunnelRequestMessage
|
||||
| CoreHostSshOpenMessage
|
||||
| CoreHostSshInputMessage
|
||||
| CoreHostSshResizeMessage
|
||||
@@ -369,7 +386,8 @@ export function isProviderToCoreMessage(value: unknown): value is ProviderToCore
|
||||
msg.type === "host_ssh_error" ||
|
||||
msg.type === "egress_tcp_open" ||
|
||||
msg.type === "egress_tcp_data" ||
|
||||
msg.type === "egress_tcp_close"
|
||||
msg.type === "egress_tcp_close" ||
|
||||
msg.type === "http_tunnel_response"
|
||||
) &&
|
||||
typeof msg.providerId === "string" &&
|
||||
msg.providerId.length > 0
|
||||
|
||||
Reference in New Issue
Block a user