fix: stream runner tran ssh output
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import type { Server, ServerWebSocket } from "bun";
|
||||
import { createHourlyJsonlWriter, logRetentionBytesForService } from "../../shared/src/rotating-jsonl";
|
||||
import { notificationStyles } from "./notification-styles";
|
||||
|
||||
@@ -11,6 +12,7 @@ interface RuntimeConfig {
|
||||
providerIngressPublicUrl: string;
|
||||
authUsername: string;
|
||||
authPassword: string;
|
||||
providerToken: string | null;
|
||||
sessionSecret: string;
|
||||
sessionTtlSeconds: number;
|
||||
logFile: string;
|
||||
@@ -61,6 +63,13 @@ interface OperationPerformanceSample {
|
||||
detail: string;
|
||||
}
|
||||
|
||||
interface FrontendWsData {
|
||||
channel: "ssh-proxy";
|
||||
upstream: WebSocket | null;
|
||||
upstreamOpen: boolean;
|
||||
pendingToUpstream: string[];
|
||||
}
|
||||
|
||||
const sessionCookieName = "unidesk_session";
|
||||
const config = readConfig();
|
||||
const logger = createLogger("frontend", config.logFile);
|
||||
@@ -142,6 +151,25 @@ function requiredEnv(name: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalEnv(name: string): string | null {
|
||||
const value = process.env[name]?.trim() ?? "";
|
||||
return value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function optionalFile(path: string): string | null {
|
||||
try {
|
||||
const value = readFileSync(path, "utf8").trim();
|
||||
return value.length > 0 ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function optionalFileEnv(name: string, fallbackPath: string | null = null): string | null {
|
||||
const path = optionalEnv(name) ?? fallbackPath;
|
||||
return path === null ? null : optionalFile(path);
|
||||
}
|
||||
|
||||
function readNumberEnv(name: string): number {
|
||||
const raw = requiredEnv(name);
|
||||
const parsed = Number(raw);
|
||||
@@ -169,6 +197,10 @@ function readConfig(): RuntimeConfig {
|
||||
providerIngressPublicUrl: requiredEnv("PROVIDER_INGRESS_PUBLIC_URL"),
|
||||
authUsername: requiredEnv("AUTH_USERNAME"),
|
||||
authPassword: requiredEnv("AUTH_PASSWORD"),
|
||||
providerToken: optionalEnv("PROVIDER_TOKEN")
|
||||
?? optionalEnv("UNIDESK_PROVIDER_TOKEN")
|
||||
?? optionalFileEnv("PROVIDER_TOKEN_FILE", "/run/secrets/unidesk_provider_token")
|
||||
?? optionalFileEnv("UNIDESK_PROVIDER_TOKEN_FILE", "/tmp/unidesk-provider-token"),
|
||||
sessionSecret: requiredEnv("SESSION_SECRET"),
|
||||
sessionTtlSeconds: readNumberEnv("SESSION_TTL_SECONDS"),
|
||||
logFile: requiredEnv("LOG_FILE"),
|
||||
@@ -744,6 +776,68 @@ async function proxyApi(req: Request, url: URL): Promise<Response> {
|
||||
return new Response(await upstream.arrayBuffer(), { status: upstream.status, headers: responseHeaders });
|
||||
}
|
||||
|
||||
function coreSshWebSocketUrl(): string {
|
||||
const url = new URL("/ws/ssh", config.coreInternalUrl);
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||
url.searchParams.set("token", config.providerToken ?? "");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function webSocketDataText(message: string | Buffer): string {
|
||||
return typeof message === "string" ? message : Buffer.from(message).toString("utf8");
|
||||
}
|
||||
|
||||
function flushSshProxyPending(ws: ServerWebSocket<FrontendWsData>): void {
|
||||
const upstream = ws.data.upstream;
|
||||
if (upstream === null || !ws.data.upstreamOpen || upstream.readyState !== WebSocket.OPEN) return;
|
||||
while (ws.data.pendingToUpstream.length > 0) upstream.send(ws.data.pendingToUpstream.shift()!);
|
||||
}
|
||||
|
||||
function closeSshProxy(ws: ServerWebSocket<FrontendWsData>, code = 1000, reason = "ssh proxy closed"): void {
|
||||
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
||||
ws.close(code, reason);
|
||||
}
|
||||
}
|
||||
|
||||
function openSshProxyUpstream(ws: ServerWebSocket<FrontendWsData>): void {
|
||||
const upstream = new WebSocket(coreSshWebSocketUrl());
|
||||
ws.data.upstream = upstream;
|
||||
upstream.addEventListener("open", () => {
|
||||
ws.data.upstreamOpen = true;
|
||||
flushSshProxyPending(ws);
|
||||
});
|
||||
upstream.addEventListener("message", (event) => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(event.data as string | Buffer);
|
||||
});
|
||||
upstream.addEventListener("close", (event) => {
|
||||
closeSshProxy(ws, event.code || 1000, event.reason || "ssh upstream closed");
|
||||
});
|
||||
upstream.addEventListener("error", () => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "ssh.error", message: "frontend ssh upstream websocket error" }));
|
||||
}
|
||||
closeSshProxy(ws, 1011, "ssh upstream websocket error");
|
||||
});
|
||||
}
|
||||
|
||||
function proxySshWebSocket(req: Request, server: Server<FrontendWsData>): Response | undefined {
|
||||
if (sessionFromRequest(req) === null) {
|
||||
return jsonResponse({ ok: false, error: "authentication required" }, 401);
|
||||
}
|
||||
if (config.providerToken === null) {
|
||||
return jsonResponse({ ok: false, error: "frontend ssh proxy is missing provider token" }, 503);
|
||||
}
|
||||
const upgraded = server.upgrade(req, {
|
||||
data: {
|
||||
channel: "ssh-proxy",
|
||||
upstream: null,
|
||||
upstreamOpen: false,
|
||||
pendingToUpstream: [],
|
||||
} satisfies FrontendWsData,
|
||||
});
|
||||
return upgraded ? undefined : jsonResponse({ ok: false, error: "websocket upgrade failed" }, 400);
|
||||
}
|
||||
|
||||
function vendorPath(pathname: string): string | null {
|
||||
if (pathname === "/vendor/react.production.min.js") return join(vendorDir, "react", "umd", "react.production.min.js");
|
||||
if (pathname === "/vendor/react-dom.production.min.js") return join(vendorDir, "react-dom", "umd", "react-dom.production.min.js");
|
||||
@@ -768,7 +862,7 @@ function isStaticAssetPath(pathname: string): boolean {
|
||||
return /\/[^/]+\.[a-z0-9]+$/iu.test(pathname);
|
||||
}
|
||||
|
||||
async function handleRequest(req: Request): Promise<Response> {
|
||||
async function handleRequest(req: Request, server: Server<FrontendWsData>): Promise<Response | undefined> {
|
||||
const url = new URL(req.url);
|
||||
logger("debug", "request", { path: url.pathname });
|
||||
try {
|
||||
@@ -779,6 +873,7 @@ async function handleRequest(req: Request): Promise<Response> {
|
||||
if (url.pathname === "/logout" && req.method === "POST") return logout();
|
||||
if (url.pathname === "/api/session") return sessionResponse(req);
|
||||
if (url.pathname === "/api/frontend-performance") return frontendPerformanceResponse(req);
|
||||
if (url.pathname === "/ws/ssh") return proxySshWebSocket(req, server);
|
||||
if (url.pathname.startsWith("/api/") || url.pathname === "/logs") return proxyApi(req, url);
|
||||
if (url.pathname === "/docs" || url.pathname.startsWith("/docs/")) return docsResponse(req, url);
|
||||
if (url.pathname === "/" || url.pathname === "/index.html") {
|
||||
@@ -798,21 +893,39 @@ async function handleRequest(req: Request): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
const server = Bun.serve({
|
||||
const server = Bun.serve<FrontendWsData>({
|
||||
port: config.port,
|
||||
hostname: "0.0.0.0",
|
||||
idleTimeout: 120,
|
||||
async fetch(req) {
|
||||
async fetch(req, server) {
|
||||
const started = performance.now();
|
||||
const url = new URL(req.url);
|
||||
let response: Response | undefined;
|
||||
try {
|
||||
response = await handleRequest(req);
|
||||
response = await handleRequest(req, server);
|
||||
return response;
|
||||
} finally {
|
||||
recordRequestPerformance(req, url.pathname, response, performance.now() - started);
|
||||
}
|
||||
},
|
||||
websocket: {
|
||||
open(ws) {
|
||||
if (ws.data.channel === "ssh-proxy") openSshProxyUpstream(ws);
|
||||
},
|
||||
message(ws, message) {
|
||||
if (ws.data.channel !== "ssh-proxy") return;
|
||||
ws.data.pendingToUpstream.push(webSocketDataText(message));
|
||||
flushSshProxyPending(ws);
|
||||
},
|
||||
close(ws) {
|
||||
if (ws.data.channel !== "ssh-proxy") return;
|
||||
try {
|
||||
ws.data.upstream?.close();
|
||||
} catch {
|
||||
// Closing an already-closed upstream socket is harmless.
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
logger("info", "server_listening", {
|
||||
|
||||
@@ -233,6 +233,11 @@ spec:
|
||||
secretKeyRef:
|
||||
name: unidesk-dev-runtime-secrets
|
||||
key: AUTH_PASSWORD
|
||||
- name: PROVIDER_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: unidesk-dev-runtime-secrets
|
||||
key: PROVIDER_TOKEN
|
||||
- name: SESSION_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
|
||||
Reference in New Issue
Block a user