diff --git a/docker-compose.yml b/docker-compose.yml index cb651ad9..713b19c4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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}" diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 483177ae..19bfeae9 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -33,7 +33,7 @@ CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 DEV/PROD 滚动、P - `ssh py [script-args...] < script.py` 把本地 stdin 落到远端临时 `.py` 文件后再以 `python3 -u` 执行并自动清理,避免再手写 `'python3 -'`、heredoc 或多层引号;`script-args` 会按 argv 安全透传给远端脚本。 - `ssh skills [--scope all|wsl|windows] [--limit N]` 发现目标节点上的 WSL/Linux skill 根目录;当 provider 是 WSL 时同一次调用还会扫描 Windows 用户目录下的 `.agents/skills` 与 `.codex/skills`。 - `ssh :k3s[:namespace:workload[:container]] ...` 是原生 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 ssh ...`,其中 `` 优先来自 `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:: argv ...`、`tran G14:/absolute/workspace apply-patch ...` 和 `tran 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 ssh ...`,其中 `` 优先来自 `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:: argv ...`、`tran G14:/absolute/workspace apply-patch ...` 和 `tran 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 token,frontend 侧用 `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 ` 将带 `# 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 [--source-file path]` 查看单日正文,`--source-file` 用于同一天存在多个导入来源时精确选择;`decision diary edit|upsert --body-file [--title text] [--source-file path] [--tag tag]` 通过 `PUT /api/diary/entries/:idOrDate` 创建当天或历史条目并编辑既有条目。 diff --git a/scripts/src/remote.ts b/scripts/src/remote.ts index b074ec58..e4fc5328 100644 --- a/scripts/src/remote.ts +++ b/scripts/src/remote.ts @@ -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 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 { @@ -901,7 +911,10 @@ function openFrontendSshWebSocket(session: FrontendSession): WebSocket { url: string, options?: { headers?: Record }, ) => 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", diff --git a/scripts/ssh-argv-guidance-contract-test.ts b/scripts/ssh-argv-guidance-contract-test.ts index 97d707f7..4579d13d 100644 --- a/scripts/ssh-argv-guidance-contract-test.ts +++ b/scripts/ssh-argv-guidance-contract-test.ts @@ -1187,6 +1187,7 @@ export async function runSshArgvGuidanceContract(): Promise { 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 { 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); diff --git a/src/components/frontend/src/index.ts b/src/components/frontend/src/index.ts index aa3c77b3..fab3edea 100644 --- a/src/components/frontend/src/index.ts +++ b/src/components/frontend/src/index.ts @@ -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): void { while (ws.data.pendingToUpstream.length > 0) upstream.send(ws.data.pendingToUpstream.shift()!); } +function handleSshProxyClientMessage(ws: ServerWebSocket, text: string): void { + if (!ws.data.sshClient.routeChecked) { + let message: Record; + try { + message = JSON.parse(text) as Record; + } 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, 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): void { } function proxySshWebSocket(req: Request, server: Server): 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): 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({ }, 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; diff --git a/src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-core.k8s.yaml b/src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-core.k8s.yaml index ce837047..c5d4209a 100644 --- a/src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-core.k8s.yaml +++ b/src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-core.k8s.yaml @@ -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: diff --git a/src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-foundation.k8s.yaml b/src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-foundation.k8s.yaml index a4a397b1..d9eb3bea 100644 --- a/src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-foundation.k8s.yaml +++ b/src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-foundation.k8s.yaml @@ -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"