feat: add scoped ssh client passthrough
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user