feat: add scoped ssh client passthrough

This commit is contained in:
Codex
2026-06-02 07:39:21 +00:00
parent 2a4f6d7791
commit 0c28961f9a
7 changed files with 116 additions and 7 deletions
+81 -3
View File
@@ -13,6 +13,8 @@ interface RuntimeConfig {
authUsername: string;
authPassword: string;
providerToken: string | null;
sshClientToken: string | null;
sshClientRouteAllowlist: string[];
sessionSecret: string;
sessionTtlSeconds: number;
logFile: string;
@@ -68,6 +70,11 @@ interface FrontendWsData {
upstream: WebSocket | null;
upstreamOpen: boolean;
pendingToUpstream: string[];
sshClient: {
authenticatedBy: "session" | "client-token";
routeAllowlist: string[];
routeChecked: boolean;
};
}
const sessionCookieName = "unidesk_session";
@@ -201,6 +208,9 @@ function readConfig(): RuntimeConfig {
?? optionalEnv("UNIDESK_PROVIDER_TOKEN")
?? optionalFileEnv("PROVIDER_TOKEN_FILE", "/run/secrets/unidesk_provider_token")
?? optionalFileEnv("UNIDESK_PROVIDER_TOKEN_FILE", "/tmp/unidesk-provider-token"),
sshClientToken: optionalEnv("UNIDESK_SSH_CLIENT_TOKEN")
?? optionalFileEnv("UNIDESK_SSH_CLIENT_TOKEN_FILE", "/run/secrets/unidesk_ssh_client_token"),
sshClientRouteAllowlist: parseRouteAllowlist(optionalEnv("UNIDESK_SSH_CLIENT_ROUTE_ALLOWLIST") ?? "G14,G14:*,D601,D601:*"),
sessionSecret: requiredEnv("SESSION_SECRET"),
sessionTtlSeconds: readNumberEnv("SESSION_TTL_SECONDS"),
logFile: requiredEnv("LOG_FILE"),
@@ -223,6 +233,11 @@ function readConfig(): RuntimeConfig {
};
}
function parseRouteAllowlist(raw: string): string[] {
const items = raw.split(",").map((item) => item.trim()).filter((item) => item.length > 0);
return items.length > 0 ? items : ["G14", "G14:*", "D601", "D601:*"];
}
function isDevIdentity(identity: RuntimeIdentity): boolean {
return identity.environment === "dev" || identity.namespace === "unidesk-dev";
}
@@ -687,6 +702,42 @@ function sessionFromRequest(req: Request): SessionPayload | null {
return verifySession(parseCookies(req.headers.get("cookie"))[sessionCookieName]);
}
function timingSafeStringEqual(left: string, right: string): boolean {
const leftBytes = Buffer.from(left, "utf8");
const rightBytes = Buffer.from(right, "utf8");
if (leftBytes.length !== rightBytes.length) return false;
return timingSafeEqual(leftBytes, rightBytes);
}
function bearerTokenFromRequest(req: Request): string | null {
const header = req.headers.get("authorization") ?? "";
const match = /^Bearer\s+(.+)$/iu.exec(header);
return match === null ? null : match[1]?.trim() ?? null;
}
function sshClientAuthFromRequest(req: Request): FrontendWsData["sshClient"] | null {
if (sessionFromRequest(req) !== null) {
return { authenticatedBy: "session", routeAllowlist: ["*"], routeChecked: true };
}
const expected = config.sshClientToken;
const supplied = bearerTokenFromRequest(req);
if (expected !== null && supplied !== null && timingSafeStringEqual(supplied, expected)) {
return { authenticatedBy: "client-token", routeAllowlist: config.sshClientRouteAllowlist, routeChecked: false };
}
return null;
}
function sshRouteAllowed(providerId: unknown, routeAllowlist: string[]): boolean {
if (routeAllowlist.includes("*")) return true;
const route = typeof providerId === "string" ? providerId : "";
if (route.length === 0) return false;
return routeAllowlist.some((pattern) => {
if (pattern === route) return true;
if (pattern.endsWith("*")) return route.startsWith(pattern.slice(0, -1));
return false;
});
}
function setSessionCookie(token: string): string {
return `${sessionCookieName}=${encodeURIComponent(token)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${config.sessionTtlSeconds}`;
}
@@ -793,6 +844,32 @@ function flushSshProxyPending(ws: ServerWebSocket<FrontendWsData>): void {
while (ws.data.pendingToUpstream.length > 0) upstream.send(ws.data.pendingToUpstream.shift()!);
}
function handleSshProxyClientMessage(ws: ServerWebSocket<FrontendWsData>, text: string): void {
if (!ws.data.sshClient.routeChecked) {
let message: Record<string, unknown>;
try {
message = JSON.parse(text) as Record<string, unknown>;
} catch {
ws.send(JSON.stringify({ type: "ssh.error", message: "invalid ssh open payload" }));
closeSshProxy(ws, 1008, "invalid ssh open payload");
return;
}
if (message.type !== "ssh.open") {
ws.send(JSON.stringify({ type: "ssh.error", message: "ssh client token requires ssh.open as first message" }));
closeSshProxy(ws, 1008, "ssh open required");
return;
}
if (!sshRouteAllowed(message.providerId, ws.data.sshClient.routeAllowlist)) {
ws.send(JSON.stringify({ type: "ssh.error", failureKind: "route-not-allowed", message: "ssh route is not allowed for this client token", route: String(message.providerId ?? "") }));
closeSshProxy(ws, 1008, "ssh route not allowed");
return;
}
ws.data.sshClient.routeChecked = true;
}
ws.data.pendingToUpstream.push(text);
flushSshProxyPending(ws);
}
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);
@@ -821,7 +898,8 @@ function openSshProxyUpstream(ws: ServerWebSocket<FrontendWsData>): void {
}
function proxySshWebSocket(req: Request, server: Server<FrontendWsData>): Response | undefined {
if (sessionFromRequest(req) === null) {
const sshClient = sshClientAuthFromRequest(req);
if (sshClient === null) {
return jsonResponse({ ok: false, error: "authentication required" }, 401);
}
if (config.providerToken === null) {
@@ -833,6 +911,7 @@ function proxySshWebSocket(req: Request, server: Server<FrontendWsData>): Respon
upstream: null,
upstreamOpen: false,
pendingToUpstream: [],
sshClient,
} satisfies FrontendWsData,
});
return upgraded ? undefined : jsonResponse({ ok: false, error: "websocket upgrade failed" }, 400);
@@ -914,8 +993,7 @@ const server = Bun.serve<FrontendWsData>({
},
message(ws, message) {
if (ws.data.channel !== "ssh-proxy") return;
ws.data.pendingToUpstream.push(webSocketDataText(message));
flushSshProxyPending(ws);
handleSshProxyClientMessage(ws, webSocketDataText(message));
},
close(ws) {
if (ws.data.channel !== "ssh-proxy") return;
@@ -238,6 +238,16 @@ spec:
secretKeyRef:
name: unidesk-dev-runtime-secrets
key: PROVIDER_TOKEN
- name: UNIDESK_SSH_CLIENT_TOKEN
valueFrom:
secretKeyRef:
name: unidesk-dev-runtime-secrets
key: UNIDESK_SSH_CLIENT_TOKEN
- name: UNIDESK_SSH_CLIENT_ROUTE_ALLOWLIST
valueFrom:
configMapKeyRef:
name: unidesk-dev-runtime-config
key: UNIDESK_SSH_CLIENT_ROUTE_ALLOWLIST
- name: SESSION_SECRET
valueFrom:
secretKeyRef:
@@ -64,6 +64,7 @@ stringData:
POSTGRES_DB: unidesk_dev
DATABASE_URL: postgres://unidesk_dev:change-me-dev-postgres-password@postgres-dev.unidesk-dev.svc.cluster.local:5432/unidesk_dev
PROVIDER_TOKEN: change-me-D601-dev-provider-token
UNIDESK_SSH_CLIENT_TOKEN: change-me-dev-scoped-ssh-client-token
AUTH_USERNAME: admin-dev
AUTH_PASSWORD: change-me-dev-auth-password
SESSION_SECRET: change-me-dev-session-secret-minimum-32-characters
@@ -88,6 +89,7 @@ data:
UNIDESK_DEV_DATABASE_NAME: unidesk_dev
UNIDESK_DEV_DATABASE_ALLOWED_HOSTS: postgres-dev,postgres-dev.unidesk-dev,postgres-dev.unidesk-dev.svc,postgres-dev.unidesk-dev.svc.cluster.local
UNIDESK_DEV_DATABASE_FORBIDDEN_PATTERNS: d601-tcp-egress-gateway,database:5432/unidesk,74.48.78.17:15432
UNIDESK_SSH_CLIENT_ROUTE_ALLOWLIST: G14,G14:*,D601,D601:*
DATABASE_VOLUME_NAME: postgres-dev-data
DATABASE_VOLUME_SIZE: 5Gi
HEARTBEAT_TIMEOUT_MS: "30000"