From fba8dfb60257f75f6ee651bc42bb53498eb088aa Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 16 Aug 2026 05:14:17 +0800 Subject: [PATCH] feat(ql3): distribute copilot console via signed admin image --- deploy/console/ql3-cluster-copilot/README.md | 76 +++++- .../ql3-cluster-copilot/docker-loopback.sh | 88 +++++++ .../host-environment.example.json | 7 + .../ql3-cluster-copilot/verify-release.sh | 58 +++++ .../containers/ql3-cluster-admin/Dockerfile | 11 + docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 1 + ...in-oci-console-workstation-distribution.md | 41 +++ docs/adr/README.md | 1 + docs/ql3-package-boundaries.json | 2 +- package.json | 1 + packages/ql3-cluster-admin/README.md | 11 +- .../src/copilot-console/cli.ts | 28 +- .../src/copilot-console/server.ts | 28 +- .../test/copilotConsole.test.cjs | 41 ++- .../test/copilotConsoleCli.test.cjs | 24 +- ...l3-cluster-admin-product-live-contract.cjs | 185 +++++++++++++- scripts/ql3-cluster-copilot-console-audit.cjs | 11 +- ...ter-copilot-console-distribution-audit.cjs | 241 ++++++++++++++++++ ...l3ClusterAdminProductLiveContract.test.cjs | 10 +- .../ql3ClusterAdminReleaseVerifier.test.cjs | 111 ++++++++ ...erCopilotConsoleDistributionAudit.test.cjs | 86 +++++++ .../ql3ClusterCopilotConsoleLauncher.test.cjs | 143 +++++++++++ 22 files changed, 1176 insertions(+), 29 deletions(-) create mode 100755 deploy/console/ql3-cluster-copilot/docker-loopback.sh create mode 100644 deploy/console/ql3-cluster-copilot/host-environment.example.json create mode 100755 deploy/console/ql3-cluster-copilot/verify-release.sh create mode 100644 docs/adr/ADR-0420-signed-admin-oci-console-workstation-distribution.md create mode 100644 scripts/ql3-cluster-copilot-console-distribution-audit.cjs create mode 100644 test/back/ql3ClusterAdminReleaseVerifier.test.cjs create mode 100644 test/back/ql3ClusterCopilotConsoleDistributionAudit.test.cjs create mode 100644 test/back/ql3ClusterCopilotConsoleLauncher.test.cjs diff --git a/deploy/console/ql3-cluster-copilot/README.md b/deploy/console/ql3-cluster-copilot/README.md index 89f9441f..9f041e46 100644 --- a/deploy/console/ql3-cluster-copilot/README.md +++ b/deploy/console/ql3-cluster-copilot/README.md @@ -1,20 +1,50 @@ # Cluster Copilot read-only Console This Console is an operator-workstation process, not a resident QingLong -service. It serves digest-bound assets on an ephemeral `127.0.0.1` port and +service. Native execution serves digest-bound assets on an ephemeral +`127.0.0.1` port and forwards only `inspect` and explicit `output` reads to the existing Cluster Copilot API. Do not deploy it as a Kubernetes workload, Ingress, shared LAN listener, Edge component or legacy 2.x Web route. Use `ql3-cluster-admin` from the same independently verified Admin release as -the Cluster deployment. The Console intentionally runs directly on the trusted -operator workstation. A container port mapping is not a supported substitute: -the process binds container loopback and must not be widened to `0.0.0.0`. +the Cluster deployment. D-328 also supports the image-carried +`docker-loopback.sh`: it uses an explicit container-only listener but publishes +the same port exclusively on host `127.0.0.1`. Arbitrary `0.0.0.0`, host +networking and LAN publication remain forbidden. + +## Verify the distribution + +The multi-architecture `qinglong3-cluster-admin@sha256:…` OCI image is the +distribution artifact. It already carries the exact launcher, examples and +this document under `/opt/qinglong/share/ql3-copilot-console/`; there is no +second Node archive or package dependency graph to trust. + +From the exact reviewed source tag, run `verify-release.sh` with the immutable +image digest, repository, 40-hex source revision and full tag ref. The verifier +requires `cosign` and authenticated `gh`, then independently checks the keyless +release-workflow identity, SLSA provenance, CycloneDX SBOM and digest-bound OS +vulnerability evidence. It rejects tags and mutable image references. + +```sh +deploy/console/ql3-cluster-copilot/verify-release.sh \ + ghcr.io/replace-owner/qinglong3-cluster-admin@sha256:REPLACE_64_HEX \ + replace-owner/qinglong \ + REPLACE_40_HEX_SOURCE_REVISION \ + refs/tags/v3.0.0-alpha.0 +``` + +After verification, pull that exact digest. The signature covers the embedded +host launcher and templates as part of the image filesystem. Operators may +either use the launcher from the matching reviewed tag or extract its exact +image-carried copy with `docker create` plus `docker cp` before execution. ## Prepare private authority -Create an absolute canonical directory owned by the current operator with mode -`0700`. Copy `client-config.example.json` to `client.json`, install the reviewed +Create an absolute canonical directory with mode `0700`. For native execution +it is owned by the current operator; for the image-carried launcher it and all +files are owned by UID/GID `10001:10001`. Copy `client-config.example.json` to +`client.json`, install the reviewed Cluster API CA as `ca.pem`, and install a separately issued `ql3c_` Project API credential as `credential`. Give the credential only `run.read` and `artifact.read`; the Console has no route for diagnosis creation or @@ -72,3 +102,37 @@ approximately 2 MiB, disables cache/cookies/frames/workers, and never polls. Model text is rendered as plain text and remains untrusted advice. These limits keep the workstation surface bounded, but this Cluster-only product is still excluded from small router Edge/Standalone artifacts. + +## Run the verified image + +Create a dedicated Docker network whose egress is restricted by the host +firewall to DNS and the exact Cluster API destination. Copy +`host-environment.example.json` values into the launcher environment, replacing +the image with the verified digest and selecting one unused host port. The +launcher rejects `bridge|default|host|none`, mutable tags, noncanonical private +roots, ports outside `1024..65535` and unknown resource classes. + +| Resource class | Memory | CPU | PIDs | Console reads | +| --- | ---: | ---: | ---: | ---: | +| `compact` | 192 MiB | 0.25 | 32 | 2, no queue | +| `standard` | 512 MiB | 1 | 64 | 2, no queue | + +Validate private authority and the upstream unauthenticated TLS 1.3 readiness +route without opening or publishing a listener: + +```sh +deploy/console/ql3-cluster-copilot/docker-loopback.sh check +``` + +Then start the foreground session: + +```sh +deploy/console/ql3-cluster-copilot/docker-loopback.sh serve +``` + +The launcher fixes non-root UID, read-only root, no capabilities, +no-new-privileges, bounded memory/CPU/PIDs, an 8 MiB noexec tmpfs, one read-only +private mount and `--pull never`. `serve` alone adds +`--publish 127.0.0.1::/tcp`; `check` publishes nothing. The +container listener is reachable only through this reviewed publication and +continues to require the 256-bit browser session token plus exact Host/Origin. diff --git a/deploy/console/ql3-cluster-copilot/docker-loopback.sh b/deploy/console/ql3-cluster-copilot/docker-loopback.sh new file mode 100755 index 00000000..e19be866 --- /dev/null +++ b/deploy/console/ql3-cluster-copilot/docker-loopback.sh @@ -0,0 +1,88 @@ +#!/bin/sh + +set -eu + +usage() { + printf '%s\n' 'Usage: docker-loopback.sh check|serve' >&2 + exit 64 +} + +fail() { + printf '%s\n' '{"schemaVersion":1,"component":"qinglong3-cluster-copilot-console-launcher","event":"launch_failed"}' >&2 + exit 78 +} + +[ "$#" -eq 1 ] || usage +mode=$1 +case "$mode" in + check|serve) ;; + *) usage ;; +esac + +image=${QL3_COPILOT_CONSOLE_IMAGE-} +private_root=${QL3_COPILOT_CONSOLE_PRIVATE_ROOT-} +network=${QL3_COPILOT_CONSOLE_NETWORK-} +port=${QL3_COPILOT_CONSOLE_PORT-} +resource_class=${QL3_COPILOT_CONSOLE_RESOURCE_CLASS-compact} + +printf '%s' "$image" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._/-]{0,191}@sha256:[0-9a-f]{64}$' || fail +printf '%s' "$network" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$' || fail +case "$network" in + bridge|default|host|none) fail ;; +esac +printf '%s' "$port" | grep -Eq '^[1-9][0-9]{3,4}$' || fail +[ "$port" -ge 1024 ] 2>/dev/null || fail +[ "$port" -le 65535 ] 2>/dev/null || fail +case "$private_root" in + /*) ;; + *) fail ;; +esac +case "$private_root" in + *','*|*':'*) fail ;; +esac +[ -d "$private_root" ] || fail +canonical_root=$(CDPATH= cd -- "$private_root" 2>/dev/null && pwd -P) || fail +[ "$canonical_root" = "$private_root" ] || fail + +case "$resource_class" in + compact) + memory=192m + cpus=0.25 + pids=32 + ;; + standard) + memory=512m + cpus=1 + pids=64 + ;; + *) fail ;; +esac + +set -- docker run --rm --pull never --init --read-only \ + --network "$network" \ + --cap-drop ALL \ + --security-opt no-new-privileges \ + --user 10001:10001 \ + --pids-limit "$pids" \ + --memory "$memory" \ + --cpus "$cpus" \ + --stop-timeout 3 \ + --tmpfs /tmp:rw,noexec,nosuid,nodev,size=8m,mode=700,uid=10001,gid=10001 \ + --mount "type=bind,src=$private_root,dst=/var/run/secrets/qinglong3/copilot-console,readonly" + +if [ "$mode" = serve ]; then + set -- "$@" --publish "127.0.0.1:$port:$port/tcp" +fi + +set -- "$@" "$image" copilot-console \ + --container-published-loopback \ + "--port=$port" \ + --config /var/run/secrets/qinglong3/copilot-console/client.json \ + --credential /var/run/secrets/qinglong3/copilot-console/credential \ + --session /var/run/secrets/qinglong3/copilot-console/session + +if [ "$mode" = check ]; then + set -- "$@" --check +fi + +exec "$@" diff --git a/deploy/console/ql3-cluster-copilot/host-environment.example.json b/deploy/console/ql3-cluster-copilot/host-environment.example.json new file mode 100644 index 00000000..b05df2e5 --- /dev/null +++ b/deploy/console/ql3-cluster-copilot/host-environment.example.json @@ -0,0 +1,7 @@ +{ + "QL3_COPILOT_CONSOLE_IMAGE": "ghcr.io/replace-owner/qinglong3-cluster-admin@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "QL3_COPILOT_CONSOLE_PRIVATE_ROOT": "/absolute/private/ql3-copilot-console", + "QL3_COPILOT_CONSOLE_NETWORK": "qinglong3-copilot-console-egress", + "QL3_COPILOT_CONSOLE_PORT": "5701", + "QL3_COPILOT_CONSOLE_RESOURCE_CLASS": "compact" +} diff --git a/deploy/console/ql3-cluster-copilot/verify-release.sh b/deploy/console/ql3-cluster-copilot/verify-release.sh new file mode 100755 index 00000000..09e9ddab --- /dev/null +++ b/deploy/console/ql3-cluster-copilot/verify-release.sh @@ -0,0 +1,58 @@ +#!/bin/sh + +set -eu + +usage() { + printf '%s\n' 'Usage: verify-release.sh IMAGE@sha256:DIGEST OWNER/REPO SOURCE_REVISION refs/tags/v3.VERSION' >&2 + exit 64 +} + +fail() { + printf '%s\n' '{"schemaVersion":1,"component":"qinglong3-cluster-admin-release-verifier","event":"verification_failed"}' >&2 + exit 78 +} + +[ "$#" -eq 4 ] || usage +image=$1 +repository=$2 +source_revision=$3 +source_ref=$4 + +printf '%s' "$repository" | grep -Eq '^[a-z0-9][a-z0-9-]{0,38}/[A-Za-z0-9_.-]{1,100}$' || fail +owner=${repository%%/*} +printf '%s' "$image" | grep -Eq "^ghcr.io/$owner/qinglong3-cluster-admin@sha256:[0-9a-f]{64}$" || fail +printf '%s' "$source_revision" | grep -Eq '^[0-9a-f]{40}$' || fail +printf '%s' "$source_ref" | grep -Eq '^refs/tags/v3\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$' || fail +command -v cosign >/dev/null 2>&1 || fail +command -v gh >/dev/null 2>&1 || fail + +workflow="$repository/.github/workflows/ql3-image-release.yml" +certificate_identity="https://github.com/$workflow@$source_ref" + +cosign verify \ + --certificate-identity "$certificate_identity" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + "$image" >/dev/null || fail + +verify_attestation() { + predicate_type=$1 + if [ -n "$predicate_type" ]; then + set -- --predicate-type "$predicate_type" + else + set -- + fi + gh attestation verify "oci://$image" \ + --repo "$repository" \ + --signer-workflow "$workflow" \ + --source-digest "$source_revision" \ + --source-ref "$source_ref" \ + "$@" \ + --deny-self-hosted-runners \ + --bundle-from-oci >/dev/null || fail +} + +verify_attestation '' +verify_attestation https://cyclonedx.org/bom +verify_attestation https://qinglong.dev/attestations/image-os-vulnerability/v1 + +printf '%s\n' '{"schemaVersion":1,"component":"qinglong3-cluster-admin-release-verifier","signature":true,"provenance":true,"sbom":true,"osVulnerabilityEvidence":true,"compatible":true}' diff --git a/deploy/containers/ql3-cluster-admin/Dockerfile b/deploy/containers/ql3-cluster-admin/Dockerfile index ef761e29..230180ca 100644 --- a/deploy/containers/ql3-cluster-admin/Dockerfile +++ b/deploy/containers/ql3-cluster-admin/Dockerfile @@ -76,6 +76,17 @@ COPY --from=workspace /workspace/packages/ql3-cluster-admin/dist \ node_modules/@qinglong/cluster-admin/dist COPY --from=workspace /workspace/packages/ql3-cluster-admin/assets/copilot-console \ node_modules/@qinglong/cluster-admin/assets/copilot-console +RUN mkdir -p share/ql3-copilot-console +COPY --chmod=0555 deploy/console/ql3-cluster-copilot/docker-loopback.sh \ + share/ql3-copilot-console/docker-loopback.sh +COPY --chmod=0555 deploy/console/ql3-cluster-copilot/verify-release.sh \ + share/ql3-copilot-console/verify-release.sh +COPY --chmod=0444 deploy/console/ql3-cluster-copilot/README.md \ + share/ql3-copilot-console/README.md +COPY --chmod=0444 deploy/console/ql3-cluster-copilot/client-config.example.json \ + share/ql3-copilot-console/client-config.example.json +COPY --chmod=0444 deploy/console/ql3-cluster-copilot/host-environment.example.json \ + share/ql3-copilot-console/host-environment.example.json FROM node:24.18.0-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d AS runtime diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 6e715bad..182a0fa9 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,6 +11,7 @@ 最新增量证据(2026-08-16): +- D-328/ADR-0420(已接受):Cluster Copilot Console 的 operator-workstation 分发已冻结为既有 `qinglong3-cluster-admin@sha256:…` 多架构 OCI,而不是再发布 Node archive、安装器镜像或第 19 个 workspace package。Admin release workflow 已有 amd64/arm64 原生构建、production dependency audit、BuildKit provenance/SBOM、OS vulnerability scan、扫描后 OCI merge、keyless Cosign signature,以及绑定 repository/workflow/source commit/source tag 的 GitHub provenance、CycloneDX 和 OS-vulnerability attestations;新增 `verify-release.sh` 要求 image digest、40-hex revision 与 `refs/tags/v3.*`,独立复验 exact workflow certificate identity、GitHub OIDC issuer、三类 predicate、OCI bundle 并拒绝 mutable tag、branch ref 与 self-hosted builder。已签名 image filesystem 现在携带 `0555` launcher/verifier 和 `0444` 文档/配置模板,因此 signature 同时覆盖宿主启动路径,不存在第二套依赖树。原生 Console 默认继续只监听 `127.0.0.1` ephemeral port;只有显式 `--container-published-loopback` 加固定高端口才允许容器 network namespace 内监听 `0.0.0.0`,并仍对外报告 `publishedHostAddress=127.0.0.1`。reviewed launcher 只接受 immutable digest、canonical private root、显式命名网络和 `compact|standard` 两档资源,拒绝 `bridge|default|host|none`;`check` 不 publish,`serve` 唯一映射为 `127.0.0.1::/tcp`,同时固定 non-root `10001:10001`、read-only root、drop ALL、no-new-privileges、8 MiB noexec tmpfs、一个只读 authority mount、`--pull never`、3 秒 stop,compact 为 192 MiB/0.25 CPU/32 PIDs,standard 为 512 MiB/1 CPU/64 PIDs。启动器/验签器/分发审计与 Console 定向门 30/30;release/OCI/SBOM 定向门 75/75,package/dependency/Edge/Cluster deployment/Console distribution 审计全部零 finding。Cluster Admin 376 pass/3 条件 skip,完整 18-package clean build/test 退出 0,backend 1,223 pass/2 条件 skip/0 fail。npm pack dry-run 保持 245 files、258,591-byte tarball、1,616,582-byte unpacked。真实 arm64 image `qinglong3-cluster-admin:d328-local` 为 344,492,529 bytes,验证 10 个产品命令、镜像内五项分发文件及 mode、原生 loopback Console;新增 named-network 现场门选择一个空闲高端口并以 exact `127.0.0.1:P:P` 发布,宿主真实读取页面,同时复验 read-only/non-root/no-capability/no-new-privileges 边界与容器/网络清理。14 档 Local artifact 全部 compatible;默认 Edge/Standalone 仍精确为 2,589,890/2,589,968 bytes、315 files、56 modules,application+AI 仍为 4,493,043/4,493,175 bytes,MCP 仍为 7,315,930/7,316,038 bytes,证明工作站分发没有进入低配路由设备闭包。workspace 保持 18 package、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,Cluster Admin 120 个源码中 119 个位于嵌套职责目录。本 Gate 无 schema、migration、SQL、role、Pool、连接或 HA 拓扑变化,继续引用 D-323 PostgreSQL 18.6 arm64 physical HA 142/142、timeline `1→2` 基线。下一独立 Gate 应在同一 Console/BFF ownership 下增加只读 Run/Task/Workflow 观察面,或补公开 release tag 的外部工作站 ceremony;不得把容器内部 listener 误当宿主 LAN authority、引入第二分发依赖树或把 Console 变成 Kubernetes 常驻服务。 - D-327/ADR-0419(已接受):QingLong 3.0 首个 Cluster 浏览器产品面已冻结为独立的 operator-workstation、loopback-only、只读 Copilot Console,而不是继续扩展 2.x Umi `src/pages`、legacy session 与 `/api` proxy。实现内聚在既有 `@qinglong/cluster-admin/copilot-console`,workspace 仍为 18 个 package;统一产品 façade 增加第十个静态命令 `copilot-console`。BFF 只监听 `127.0.0.1` ephemeral port,启动前复验包内 HTML/CSS/JS 的路径、realpath、类型、UTF-8、大小与固定 SHA-256;三项资源合计 24,150 bytes,无外部 asset/font/CDN。Cluster `ql3c_` credential 始终留在服务端 owner-private `0600` 文件且每次上游调用重新读取;浏览器只使用另一份 exact 256-bit session key,服务端只保存 domain-separated digest,页面只保存在内存,不进入 cookie、URL、argv、environment、local/session storage。Browser BFF 仅接受 exact `inspect|output`,复用 D-324 共享 TypeScript client,不执行 CLI 子进程、不直连数据库/application capability,并明确没有 diagnose/cancel、poller、WebSocket/SSE、ServiceWorker、queue/retry/cache 或后台 timer。Host、Origin、单 Authorization、route/operation 和 JSON framing 必须 exact;第三个并发 read 立即 `429`,固定 4 KiB request、约 2 MiB response、2 in-flight、16 connections 和 2 秒 shutdown ceiling。响应全为 `no-store` 且使用 default-deny CSP;模型文本只通过 `textContent` 显示并持续标记为 untrusted/no-action-authority。部署手册固定受信运维工作站生命周期,禁止 Kubernetes workload、Ingress、sidecar、共享 LAN 和容器 `0.0.0.0`;Edge/Standalone、Local MCP、Cluster Control/AI closure 均不导入 Console。npm pack dry-run 确认 245 files、258,012-byte tarball、1,614,503-byte unpacked,包含三项静态资源与全部 BFF/CLI 编译产物;独立审计还发现并修正真实 Admin Dockerfile 原先遗漏 assets 的发布缺陷,并把生产 files 白名单精确收窄到 `assets/copilot-console/*`。真实 Playwright 现场门覆盖 session 解锁、status read、显式 output reveal、390px 响应式布局和键盘路径;含 `