feat: add scoped ssh client passthrough
This commit is contained in:
+17
-3
@@ -56,6 +56,7 @@ export interface AutoRemoteCiPublishPlan {
|
||||
interface FrontendSession {
|
||||
baseUrl: string;
|
||||
cookie: string;
|
||||
sshClientToken: string | null;
|
||||
}
|
||||
|
||||
interface FetchJsonResult {
|
||||
@@ -519,7 +520,16 @@ async function loginFrontend(host: string, config: UniDeskConfig): Promise<Front
|
||||
}
|
||||
const cookie = res.responseHeaders?.["set-cookie"]?.split(";")[0] ?? "";
|
||||
if (cookie.length === 0) throw new RemoteCliFailure("auth-missing", `frontend login via ${baseUrl} did not return a session cookie`, { baseUrl, status: res.status ?? null });
|
||||
return { baseUrl, cookie };
|
||||
return { baseUrl, cookie, sshClientToken: null };
|
||||
}
|
||||
|
||||
function sshClientTokenFromEnv(env: NodeJS.ProcessEnv = process.env): string | null {
|
||||
const token = env.UNIDESK_SSH_CLIENT_TOKEN?.trim() ?? "";
|
||||
return token.length > 0 ? token : null;
|
||||
}
|
||||
|
||||
function scopedSshFrontendSession(host: string, config: UniDeskConfig, token: string): FrontendSession {
|
||||
return { baseUrl: frontendBaseUrl(host, config), cookie: "", sshClientToken: token };
|
||||
}
|
||||
|
||||
async function frontendJson(session: FrontendSession, path: string, init?: RequestInit, timeoutMs = 8000, maxResponseBytes = 5_000_000): Promise<FetchJsonResult> {
|
||||
@@ -901,7 +911,10 @@ function openFrontendSshWebSocket(session: FrontendSession): WebSocket {
|
||||
url: string,
|
||||
options?: { headers?: Record<string, string> },
|
||||
) => WebSocket;
|
||||
return new WebSocketWithHeaders(frontendSshWebSocketUrl(session), { headers: { cookie: session.cookie } });
|
||||
const headers = session.sshClientToken === null
|
||||
? { cookie: session.cookie }
|
||||
: { authorization: `Bearer ${session.sshClientToken}` };
|
||||
return new WebSocketWithHeaders(frontendSshWebSocketUrl(session), { headers });
|
||||
}
|
||||
|
||||
async function runRemoteSshWebSocket(
|
||||
@@ -1287,8 +1300,9 @@ async function runRemoteCliOverFrontend(options: RemoteCliOptions, config: UniDe
|
||||
const args = options.args.length === 0 ? ["help"] : options.args;
|
||||
const name = commandName(args);
|
||||
try {
|
||||
const session = await loginFrontend(options.host, config);
|
||||
const [top, sub] = args;
|
||||
const scopedSshToken = top === "ssh" ? sshClientTokenFromEnv() : null;
|
||||
const session = scopedSshToken === null ? await loginFrontend(options.host, config) : scopedSshFrontendSession(options.host, config, scopedSshToken);
|
||||
if (top === "help" || top === "--help" || top === "-h") {
|
||||
emitRemoteJson(name, {
|
||||
transport: "frontend",
|
||||
|
||||
@@ -1187,6 +1187,7 @@ export async function runSshArgvGuidanceContract(): Promise<JsonRecord> {
|
||||
const remoteSource = readFileSync(new URL("./src/remote.ts", import.meta.url), "utf8");
|
||||
assertCondition(remoteSource.includes("UNIDESK_REMOTE_HTTP_CLIENT") && remoteSource.includes("isCodeQueueRunnerEnv(env) ? \"curl\" : \"fetch\""), "remote frontend transport must default to curl HTTP in Code Queue runner environments", remoteSource);
|
||||
assertCondition(remoteSource.includes("frontendSshWebSocketUrl") && remoteSource.includes("runRemoteSshWebSocket"), "remote frontend ssh must go through the streaming websocket implementation", remoteSource);
|
||||
assertCondition(remoteSource.includes("UNIDESK_SSH_CLIENT_TOKEN") && remoteSource.includes("authorization: `Bearer ${session.sshClientToken}`"), "remote frontend ssh must support scoped bearer-token clients without frontend admin login", remoteSource);
|
||||
assertCondition(!remoteSource.includes("remote frontend transport does not stream stdin"), "remote frontend ssh must not reject stdin-backed helpers", remoteSource);
|
||||
assertCondition(!remoteSource.includes("source: \"cli-remote-ssh\""), "remote frontend ssh must not use host.ssh dispatch task polling", remoteSource);
|
||||
|
||||
@@ -1200,6 +1201,8 @@ export async function runSshArgvGuidanceContract(): Promise<JsonRecord> {
|
||||
assertCondition(frontendSource.includes('url.pathname === "/ws/ssh"') && frontendSource.includes("proxySshWebSocket"), "frontend must expose an authenticated /ws/ssh proxy", frontendSource);
|
||||
assertCondition(frontendSource.includes("coreSshWebSocketUrl") && frontendSource.includes('url.searchParams.set("token"'), "frontend /ws/ssh proxy must connect to backend-core ssh bridge with the provider token", frontendSource);
|
||||
assertCondition(frontendSource.includes("PROVIDER_TOKEN_FILE") && frontendSource.includes("/run/secrets/unidesk_provider_token"), "frontend ssh proxy must support file-based provider token injection for runtime hotfix and secret mounts", frontendSource);
|
||||
assertCondition(frontendSource.includes("UNIDESK_SSH_CLIENT_TOKEN") && frontendSource.includes("UNIDESK_SSH_CLIENT_ROUTE_ALLOWLIST"), "frontend ssh proxy must support scoped client-token configuration", frontendSource);
|
||||
assertCondition(frontendSource.includes("route-not-allowed") && frontendSource.includes("sshRouteAllowed") && frontendSource.includes("ssh.open"), "frontend ssh proxy must reject disallowed scoped-client routes before opening a provider session", frontendSource);
|
||||
|
||||
const composeSource = readFileSync(new URL("../docker-compose.yml", import.meta.url), "utf8");
|
||||
assertCondition(composeSource.includes('PROVIDER_TOKEN: "${UNIDESK_PROVIDER_TOKEN}"'), "frontend compose service must receive provider token for the ssh proxy", composeSource);
|
||||
|
||||
Reference in New Issue
Block a user