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
+2
View File
@@ -384,6 +384,8 @@ services:
AUTH_USERNAME: "${UNIDESK_AUTH_USERNAME}"
AUTH_PASSWORD: "${UNIDESK_AUTH_PASSWORD}"
PROVIDER_TOKEN: "${UNIDESK_PROVIDER_TOKEN}"
UNIDESK_SSH_CLIENT_TOKEN: "${UNIDESK_SSH_CLIENT_TOKEN:-}"
UNIDESK_SSH_CLIENT_ROUTE_ALLOWLIST: "${UNIDESK_SSH_CLIENT_ROUTE_ALLOWLIST:-G14,G14:*,D601,D601:*}"
SESSION_SECRET: "${UNIDESK_SESSION_SECRET}"
SESSION_TTL_SECONDS: "${UNIDESK_SESSION_TTL_SECONDS}"
UNIDESK_DEPLOY_REF: "${UNIDESK_FRONTEND_DEPLOY_REF:-deploy.json#environments.prod.services.frontend}"
+1 -1
View File
@@ -33,7 +33,7 @@ CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 DEV/PROD 滚动、P
- `ssh <providerId> py [script-args...] < script.py` 把本地 stdin 落到远端临时 `.py` 文件后再以 `python3 -u` 执行并自动清理,避免再手写 `'python3 -'`、heredoc 或多层引号;`script-args` 会按 argv 安全透传给远端脚本。
- `ssh <providerId> skills [--scope all|wsl|windows] [--limit N]` 发现目标节点上的 WSL/Linux skill 根目录;当 provider 是 WSL 时同一次调用还会扫描 Windows 用户目录下的 `.agents/skills``.codex/skills`
- `ssh <providerId>:k3s[:namespace:workload[:container]] <operation> ...` 是原生 k3s 结构化 route 入口,route 只定位控制面或 workload`kubectl``logs``exec``script``apply-patch`、旧 `apply-patch-v1` fallback 和普通容器命令作为 operation 放在 route 之后;CLI 固定注入 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml` 并把 kubectl、workload exec、logs 和 pod workspace 读写参数组装成 argv,避免在 Host SSH、bash、kubectl exec 和容器 shell 之间反复手写多层引号;D601 与 G14 都有 provider-specific guard,分别校验 `d601` 和 G14 k3s 节点身份。
- Code Queue runner 镜像必须在 PATH 上提供 `/usr/local/bin/tran`。runner 内的 `tran` 检测到 `CODE_QUEUE_*``KUBERNETES_SERVICE_HOST` 后,默认执行 `bun /root/unidesk/scripts/cli.ts --main-server-ip <public-frontend> ssh ...`,其中 `<public-frontend>` 优先来自 `UNIDESK_MAIN_SERVER_IP` / `UNIDESK_MAIN_SERVER_HOST` / `CODE_QUEUE_DEV_CONTAINER_MASTER_HOST`。runner remote frontend HTTP 客户端默认使用 `curl` 后端,降低 Bun 在部分 runner 内读取非 SSH HTTP response body 时触发 native crash 的风险;显式 `UNIDESK_REMOTE_HTTP_CLIENT=fetch` 可用于诊断。runner 内跨 D601/G14 的分布式访问应优先使用结构化 route/operation,例如 `tran D601 argv ...``tran G14 argv ...``tran D601:k3s kubectl ...``tran D601:k3s:<namespace>:<workload> argv ...``tran G14:/absolute/workspace apply-patch ...``tran <route> upload|download ...``apply-patch``upload``download``script``py` 和旧 `apply-patch-v1` fallback 经 frontend `/ws/ssh` 通道执行,stdout/stderr 也必须完整直通,不得退回 `/api/dispatch` task JSON。
- Code Queue runner 镜像必须在 PATH 上提供 `/usr/local/bin/tran`。runner 内的 `tran` 检测到 `CODE_QUEUE_*``KUBERNETES_SERVICE_HOST` 后,默认执行 `bun /root/unidesk/scripts/cli.ts --main-server-ip <public-frontend> ssh ...`,其中 `<public-frontend>` 优先来自 `UNIDESK_MAIN_SERVER_IP` / `UNIDESK_MAIN_SERVER_HOST` / `CODE_QUEUE_DEV_CONTAINER_MASTER_HOST`。runner remote frontend HTTP 客户端默认使用 `curl` 后端,降低 Bun 在部分 runner 内读取非 SSH HTTP response body 时触发 native crash 的风险;显式 `UNIDESK_REMOTE_HTTP_CLIENT=fetch` 可用于诊断。runner 内跨 D601/G14 的分布式访问应优先使用结构化 route/operation,例如 `tran D601 argv ...``tran G14 argv ...``tran D601:k3s kubectl ...``tran D601:k3s:<namespace>:<workload> argv ...``tran G14:/absolute/workspace apply-patch ...``tran <route> upload|download ...``apply-patch``upload``download``script``py` 和旧 `apply-patch-v1` fallback 经 frontend `/ws/ssh` 通道执行,stdout/stderr 也必须完整直通,不得退回 `/api/dispatch` task JSON。非 UniDesk admin runner 可通过 `UNIDESK_SSH_CLIENT_TOKEN` 使用 scoped `/ws/ssh` client tokenfrontend 侧用 `UNIDESK_SSH_CLIENT_ROUTE_ALLOWLIST` 约束允许 route;该 token 只授予 ssh passthrough,不下发 provider token、主 server SSH key 或完整 frontend 登录态。
- `microservice list/status/health/diagnostics/tunnel-self-test/proxy` 通过 backend-core 内网 API 管理挂载在计算节点 Docker 或 k3s 控制面中的用户服务(底层命令名仍为 microservice);`health``status``diagnostics` 默认返回 compact summary、body 字节数和 `--full|--raw` 展开命令,只有小 body 或无法抽取 summary 时才带有界 body preview,避免 Code Queue/k3s 诊断一次性输出爆炸;`tunnel-self-test``proxy` 会走真实 backend-core -> provider-gateway 或 k3sctl-adapter -> 节点服务链路。`microservice health code-queue` 使用 commander-safe 专用摘要,必须保留 ok/status、service id、running count、queue count、heartbeat freshness/risk、split-brain/live/degraded 解释和 raw drill-down 命令;需要完整健康 JSON 时显式加 `--raw``--full`,等价深挖路径是 `microservice proxy code-queue /health --raw --full``proxy` 支持受控 JSON 请求体并对超大响应 body 默认输出有界预览,规则见 `docs/reference/microservices.md`
- `decision upload/list/show/health` 通过 backend-core 用户服务代理访问 D601 k3s Decision Center,用于上传会议记录/决议 Markdown、列出权威记录、查看详情和健康检查;`decision list` 默认只返回摘要并省略完整 Markdown body,需要排查大正文时显式加 `--include-body`。正式文书字段通过 records 模型一等字段返回和查询:`--doc-no DC-...``--doc-type DCSN|GOAL|PLAN|RPRT|ACTN|ISSU|RETR|RQST|RESP|MINS``--doc-priority P0|P1|P2|P3``--year YYYY``--signer``--issued-at``--effective-scope``--supersedes``--superseded-by``show``requirement update` 可使用 `id``docNo``decision requirement list/create/upsert/update/show` 在同一 records 模型上管理 `goal|decision|blocker|debt|experiment` 需求记录,`docNo` 唯一,未传 `--doc-no` 但提供 `--doc-type/--doc-priority/--year` 时由服务分配下一个序号。它们不得直连 D601 Service、NodePort 或 provider-gateway 业务 HTTP。
- `decision diary import <markdown-file>` 将带 `# YYYY年M月D日``# YYYY-MM-DD``# YYYY/M/D` 标题的工作日志拆成每天一篇 Markdown 日记,按 `YYYY-MM/YYYY-MM-DD.md` 虚拟路径写入 Decision Center PostgreSQL`decision diary list/history` 默认只返回摘要,需要完整 Markdown 时显式加 `--include-body``decision diary show <YYYY-MM-DD|id> [--source-file path]` 查看单日正文,`--source-file` 用于同一天存在多个导入来源时精确选择;`decision diary edit|upsert <YYYY-MM-DD|id> --body-file <path> [--title text] [--source-file path] [--tag tag]` 通过 `PUT /api/diary/entries/:idOrDate` 创建当天或历史条目并编辑既有条目。
+17 -3
View File
@@ -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);
+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"