mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): distribute copilot console via signed admin image
This commit is contained in:
@@ -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:<port>:<port>/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.
|
||||
|
||||
+88
@@ -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 "$@"
|
||||
@@ -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"
|
||||
}
|
||||
+58
@@ -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}'
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:<port>:<port>/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 响应式布局和键盘路径;含 `<script>` 的模型输出保持纯文本,最终 0 error/0 warning,并修正了现代 HTML `/v` pattern 对未转义 `-` 的兼容问题。Console contract/CLI 12/12、定向产品入口 25/25、Cluster Admin 374 pass/3 条件 skip、完整 18-package clean build/test 退出 0、backend 1,215 pass/2 条件 skip/0 fail;package/dependency/Edge import/Cluster deployment/Console 审计零 finding,OCI/release 64/64、SBOM 11/11。真实 arm64 Admin image `qinglong3-cluster-admin:d327-local` 为 344,479,739 bytes,在 `10001:10001`、read-only root、network none、drop ALL、no-new-privileges、0.25 CPU、128 MiB/32 PIDs 下验证 10 个产品命令,并在同一受限容器内真实启动 Console、读取 digest-bound 页面与干净关闭。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,证明 Cluster UI 没有进入低配路由设备。本 Gate 无 schema、migration、SQL、role、Pool、连接或 HA 拓扑变化,因此不重跑物理 HA,继续引用 D-323 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线。下一独立 Gate 应交付可独立验签的 operator-workstation Admin/Console 分发物,或在同一 3.0 Console ownership 下增加受同一 BFF 约束的只读 Run/Task/Workflow 观察面;不得回接 2.x session、把浏览器变成 Cluster credential holder,或把 Console 变为常驻 Kubernetes 服务。
|
||||
- D-326/ADR-0418(已接受):Cluster Copilot MCP 已补齐明确的外部 host 部署与资源边界。MCP 仍是 stdio 子进程,必须由支持 MCP 的外部 host 按 session 启动并持有 stdin/stdout;它不部署为 Kubernetes Deployment/Service,否则会形成没有消费者却长期持有 Project credential 的孤儿进程。新增 `deploy/mcp/ql3-cluster-copilot/` 提供 digest-pinned host 配置、owner-private client/MCP 配置示例和固定 Docker launcher;launcher 只允许显式命名网络与 `compact|standard|dense` 三档资源,分别限制为 192 MiB/0.25 CPU/32 PIDs/并发 1、512 MiB/1 CPU/64 PIDs/并发 4、1 GiB/2 CPU/96 PIDs/并发 16,并强制 `--pull never --init --read-only --cap-drop ALL --security-opt no-new-privileges --user 10001:10001`,只读挂载一个私有 authority root,禁止 Docker socket、Kubernetes token、数据库 credential、host/default/bridge/none 网络和可写工作目录。统一产品入口新增第九个静态命令 `ql3-cluster-admin copilot-mcp`;`ql3-copilot-mcp --check` 会先复验私有 config/credential/CA,再用无认证、固定 `GET /readyz` 做低敏预检,并在启动前拒绝配置并发超过 host resource class ceiling,serve 路径仍保持无 listener、无 queue/retry/poller/cache。部署审计同时禁止任何 Kubernetes YAML 常驻该 MCP,并修正了一个真实发布缺陷:OCI layout 旧 fixture 仍声称 Admin 镜像入口是 recovery CLI,现已与真实 `product-cli/cli.js` entrypoint 对齐。workspace 仍为 18 package、无 single-source/shallow package;Cluster Admin 保持 116 个源码、115 个位于嵌套职责目录,Admin SBOM 保持 91 components/87 external/4 internal,Control 和全部 Local 闭包不变。专项发布审计 145/145、Cluster Admin 362 pass/3 条件 skip、18-package clean build/test 退出 0、backend 1,210 pass/2 条件 skip/0 fail,package/dependency/Edge import/Cluster deployment 审计零 finding。真实 arm64 Admin image `qinglong3-cluster-admin:d326-local` 为 344,423,357 bytes,在 `10001:10001`、read-only root、network none、drop ALL、no-new-privileges、0.25 CPU、128 MiB/32 PIDs 下验证 9 个产品命令与新 entrypoint。14 档 Local artifact 全部逐档复验且与 D-325 完全一致:默认 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,证明 Cluster MCP host 部署没有进入低配路由设备。本 Gate 无 schema、migration、SQL、role、Pool、连接或 HA 拓扑变化,因此不重跑物理 HA,继续引用 D-323 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线。下一独立 Gate 应冻结 Cluster UI ownership/read-only Copilot surface,或使用公开 release digest 补真实外部 host session 证据;均不得把 stdio MCP 改成常驻服务或扩大其 credential/网络 authority。
|
||||
- D-325/ADR-0417(已接受):Cluster Copilot 现已提供独立、受限、可部署的 MCP stdio 产品面。实现没有扩展旧 2.x Web UI,也没有把 Cluster authority 塞入 Edge/Standalone 的 `@qinglong/local-mcp-server`;而是在既有 `@qinglong/cluster-admin` 的内聚 `copilot-mcp/` 目录新增 `ql3-copilot-mcp` 与 `./copilot-mcp` export,workspace 仍保持 18 package。四个静态 Tool 只接收 Project、source Run、diagnosis request、trace/mutation identity,并直接调用 D-324 的共享 TypeScript client;不启动 CLI 子进程、不写 command 临时文件、不监听网络、不直连数据库/application capability,也不允许调用者提供 URL、header、credential、Model/Provider、Artifact、usage/cost 或 Policy fence。owner-private 0600 配置只保存 client config/credential 路径和显式 `1..16` 并发上限;credential 每次 Tool call 都重新执行 canonical/private/TOCTOU 与 token 校验,rotation 下一次调用立即生效。满载即时返回 `copilot_mcp_busy`,没有隐藏 queue、retry、poller、timer、watcher 或 cache。所有结果使用 exact `qinglong/cluster-copilot-mcp-result@v1`,固定 `instructionPolicy=data_only_never_execute` 与 `actionAuthority=none`;只有 output Tool 标为 `potentially_sensitive`/`untrusted_model_output`,远端错误仅投影有界 status/code/request identity/Retry-After。真实 stdio + TLS 1.3 E2E 已覆盖 initialize、discovery、四次直接请求、Bearer credential 热轮换、无 client certificate、敏感输出标注与 graceful close;并发和未知字段均失败关闭。Cluster Admin 完整测试 361 pass/3 条件 skip,18-package clean build/test 退出 0,backend 1,207 pass/2 条件 skip/0 fail;package/dependency/Edge import/Cluster deployment 四项审计零 finding,Cluster Admin 为 116 个源码且 115 个位于嵌套职责目录。Cluster Admin 镜像精确加入已固定的 `@modelcontextprotocol/server@2.0.0`,SBOM 为 91 components/87 external/4 internal;Cluster Control 和全部 Local 闭包不变。14 档 Local artifact 全部通过,默认 Edge/Standalone 仍为 2,589,890/2,589,968 bytes、315 files、56 modules,证明 Cluster MCP 没有进入低配路由设备;本 Gate 无 schema、migration、SQL、role、Pool、连接或部署拓扑变化,因此不重跑物理 HA,继续引用 D-323 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线。下一独立 Gate 应冻结 Cluster UI ownership 或增加 MCP host 的明确部署清单/运维面,仍必须复用同一 API,不得回接 2.x controller/session 或扩大 credential authority。
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# ADR-0420:以签名 Admin OCI 分发 Cluster Copilot 工作站 Console
|
||||
|
||||
- 状态:Accepted
|
||||
- 日期:2026-08-16
|
||||
- 关联 RFC:QL-RFC-0001 D-328、Phase 2
|
||||
|
||||
## 背景
|
||||
|
||||
D-327 已把首个 QingLong 3.0 浏览器产品面冻结为 `@qinglong/cluster-admin` 内聚的短生命周期、只读 Console,但只证明了本机 loopback 进程与 Admin image 内运行。运维者仍需要一条可以独立验证、在 amd64/arm64 工作站一致部署、不会另造依赖树的分发路径。
|
||||
|
||||
当前 release workflow 已对 Admin OCI 的两个原生架构执行 production dependency audit、SBOM 生成、OS vulnerability scan、OCI 合并复验、keyless Cosign 签名和三类 GitHub attestation,并在提升 immutable tag 前从 registry 独立复验。再发布 Node tarball、安装器镜像或第 19 个 workspace package,会产生第二份版本/签名/依赖闭包,也会重新引入用户已指出的薄包问题。
|
||||
|
||||
容器内部不能监听宿主 `127.0.0.1` 后再由 Docker publish;若要提供宿主 loopback 入口,容器内必须监听其 network namespace 的 all-interface 地址。因此需要把“容器内部 listener”与“宿主可达边界”拆成两个显式、可审计的概念,不能把原生默认 listener 静默改宽。
|
||||
|
||||
## 决策
|
||||
|
||||
1. 唯一工作站分发物是 `qinglong3-cluster-admin@sha256:…` 多架构 OCI;不新增 archive、安装器 image、workspace package 或生产依赖。镜像签名覆盖 Console 代码、digest-bound assets、启动器、验签器、配置模板与部署文档。
|
||||
2. 镜像内 `/opt/qinglong/share/ql3-copilot-console/` 固定携带只读文档/模板和 `0555` 的 `docker-loopback.sh`、`verify-release.sh`。运维者从 exact reviewed tag 使用脚本,或从已验证 digest 的 image filesystem 提取同一副本。
|
||||
3. `verify-release.sh` 只接受与 repository owner 一致的 Admin image digest、40-hex source revision 和 `refs/tags/v3.*`。它必须验证 exact release workflow certificate identity、GitHub OIDC issuer,以及绑定 repository、workflow、commit、tag 的 provenance、CycloneDX SBOM 和 OS vulnerability attestation;拒绝 tag image、branch ref、self-hosted builder 和非 OCI bundle。
|
||||
4. 原生 CLI 默认仍为 `host-loopback`,只监听 `127.0.0.1` 且允许 ephemeral port。只有显式 `--container-published-loopback` 加固定 `1024..65535` port 才让容器内监听 `0.0.0.0`;start/preflight fact 始终声明 `publishedHostAddress=127.0.0.1`,该 mode 不能被普通原生启动隐式选择。
|
||||
5. image launcher 只使用 immutable digest 和显式命名网络,拒绝 `bridge|default|host|none`。`serve` 只能添加 `--publish 127.0.0.1:<port>:<port>/tcp`;`check` 不开放 listener 或 publish。任意 LAN/all-interface 宿主发布不在受支持面内。
|
||||
6. launcher 固定 non-root `10001:10001`、read-only root、drop ALL、no-new-privileges、8 MiB noexec tmpfs、一个只读 private authority mount、`--pull never` 和 3 秒 stop ceiling。`compact` 为 192 MiB/0.25 CPU/32 PIDs,`standard` 为 512 MiB/1 CPU/64 PIDs;两档均继承 Console 2 reads/no queue 的应用边界。
|
||||
7. 宿主必须给 Console 建独立命名网络,并在宿主 firewall 将 egress 收窄到 DNS 和 exact Cluster API。launcher 不挂载 Docker socket、Kubernetes token、数据库 credential 或可写工作目录,也不声称 Docker bridge 本身提供 egress allowlist。
|
||||
8. Console 继续排除在 Kubernetes YAML、Edge/Standalone、Local MCP、Cluster Control/AI closure 和 2.x Web 中;因此低配路由设备默认制品不承受新增字节、module、进程或常驻资源。
|
||||
|
||||
## 不选择
|
||||
|
||||
- **独立 Node tarball/桌面安装器**:会复制依赖闭包、签名与升级通道,当前没有独立 consumer 或 package 边界价值。
|
||||
- **新增 Console workspace package**:实现仍由同一个 Cluster Admin consumer、release image 和权限域拥有,拆包只会得到浅目录或单职责文件包。
|
||||
- **容器使用 host network**:绕过明确的 port binding,并扩大到宿主全部网络面。
|
||||
- **直接发布容器 `0.0.0.0` 到宿主**:使局域网可达性依赖 daemon 默认,违反短生命周期受信工作站边界。
|
||||
- **常驻 Kubernetes Console**:会把 Project credential、listener 和资源成本变成长生命周期集群工作负载。
|
||||
|
||||
## 验收
|
||||
|
||||
1. launcher 单测精确比较 compact check 与 standard serve 的 Docker argv,并证明 tag、ambient network、低端口、非 canonical/注入式 private root 和未知资源档在调用 Docker 前低敏失败。
|
||||
2. verifier 单测用独立 fake `cosign`/`gh` 证明一次 signature 与三次 attestation 调用的 exact identity/source/predicate 约束,并证明 mutable/unbound 输入不会触发 trust tool。
|
||||
3. 分发审计锁定镜像内文件 mode/path、双架构 release workflow、signature/attestation、显式 network boundary、host loopback publication 和 Kubernetes/Edge 缺席。
|
||||
4. 真实 Admin image 必须在受限容器内证明嵌入文件与 mode,并先选择一个空闲高端口、再通过 named network + Docker `127.0.0.1:<port>:<port>` 现场证明唯一 loopback publication、exact Host/Origin 页面可读、read-only/non-root/no-capability 边界与干净回收。
|
||||
5. Cluster Admin、18-package clean build/test、backend、release/SBOM/package/dependency/Edge 审计与 14 档 Local artifact 全部通过后,本 ADR 才转为 Accepted 并进行 D-328 阶段提交。
|
||||
6. 本 Gate 不修改 schema、migration、SQL、role、Pool、连接或 PostgreSQL HA 拓扑;继续引用 D-323 PostgreSQL 18.6 arm64 physical HA 基线,不以重复数据库门代替本阶段的分发验证。
|
||||
@@ -423,6 +423,7 @@
|
||||
| [ADR-0417](./ADR-0417-bounded-cluster-copilot-mcp-stdio-surface.md) | 有界 Cluster Copilot MCP stdio 产品面 | Accepted |
|
||||
| [ADR-0418](./ADR-0418-explicit-cluster-copilot-mcp-host-deployment.md) | 显式 Cluster Copilot MCP Host 部署与资源边界 | Accepted |
|
||||
| [ADR-0419](./ADR-0419-loopback-read-only-cluster-copilot-console.md) | Loopback-only Cluster Copilot 只读 Console | Accepted |
|
||||
| [ADR-0420](./ADR-0420-signed-admin-oci-console-workstation-distribution.md) | 以签名 Admin OCI 分发 Cluster Copilot 工作站 Console | Accepted |
|
||||
|
||||
## 规则
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
"loopback-only read-only Copilot Console",
|
||||
"one-shot maintenance"
|
||||
],
|
||||
"rationale": "独立管理进程、一次性高权限命令和 operator-workstation Console 必须排除在常驻 cluster-control 之外;Approval 的 service/transport/process/client 与只读 Copilot Console 均属于同一 Cluster Admin 制品,分别使用内聚领域目录而不是新增微包。"
|
||||
"rationale": "独立管理进程、一次性高权限命令和 operator-workstation Console 必须排除在常驻 cluster-control 之外;Approval 的 service/transport/process/client 与只读 Copilot Console 均属于同一 Cluster Admin 制品,分别使用内聚领域目录而不是新增微包。Console 的宿主启动器、配置模板与发布验签脚本随已签名的多架构 Admin OCI 分发,不形成第 19 个 workspace package 或第二套依赖树。"
|
||||
},
|
||||
{
|
||||
"path": "packages/ql3-cluster-control",
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
"test:postgres-backup-prompt-output-recovery-live:ql3": "pnpm --filter @qinglong/ai build && pnpm --filter @qinglong/cluster-admin build && node scripts/ql3-postgres-prompt-output-recovery-live-contract.cjs",
|
||||
"audit:cluster-deployment:ql3": "node scripts/ql3-cluster-deployment-audit.cjs",
|
||||
"audit:cluster-copilot-console:ql3": "node scripts/ql3-cluster-copilot-console-audit.cjs",
|
||||
"audit:cluster-copilot-console-distribution:ql3": "node scripts/ql3-cluster-copilot-console-distribution-audit.cjs",
|
||||
"test:cluster-admin-product-live:ql3": "node scripts/ql3-cluster-admin-product-live-contract.cjs",
|
||||
"audit:provider-credential-management-deployment:ql3": "node scripts/ql3-provider-credential-management-deployment-audit.cjs",
|
||||
"audit:provider-credential-test-deployment:ql3": "node scripts/ql3-provider-credential-test-deployment-audit.cjs",
|
||||
|
||||
@@ -16,10 +16,13 @@ Host/Origin, no-store responses and a closed CSP, renders model text only via
|
||||
`textContent`, and keeps diagnose/cancel, polling, cache, WebSocket,
|
||||
ServiceWorker and legacy session authority absent.
|
||||
|
||||
The reviewed operator-workstation setup, private-file ceremony, preflight and
|
||||
session lifecycle are documented in
|
||||
`deploy/console/ql3-cluster-copilot/README.md`. Do not expose the Console
|
||||
through a container port mapping, Kubernetes workload or shared network.
|
||||
The reviewed operator-workstation setup, private-file ceremony, release
|
||||
verification, preflight and session lifecycle are documented in
|
||||
`deploy/console/ql3-cluster-copilot/README.md`. Native execution binds host
|
||||
loopback directly. The signed Admin OCI also carries an exact launcher which
|
||||
uses a container-internal listener only with a fixed publication on host
|
||||
`127.0.0.1`; arbitrary port mappings, Kubernetes workloads and shared-network
|
||||
listeners remain forbidden.
|
||||
|
||||
The admin role can append Identity/API Credential mutations and their security
|
||||
audit in one serializable transaction, and can perform bounded read-only audit
|
||||
|
||||
@@ -18,8 +18,9 @@ const USAGE = [
|
||||
'Usage:',
|
||||
' ql3-copilot-console --config /absolute/client.json --credential /absolute/credential --session /absolute/session [--port=0..65535]',
|
||||
' ql3-copilot-console --check --config /absolute/client.json --credential /absolute/credential --session /absolute/session',
|
||||
' ql3-copilot-console --container-published-loopback --port=1024..65535 --config /absolute/client.json --credential /absolute/credential --session /absolute/session [--check]',
|
||||
'',
|
||||
'The Console binds only 127.0.0.1 and exposes inspect/output reads.',
|
||||
'Native mode binds 127.0.0.1. Container mode requires host-loopback port publication.',
|
||||
'The browser session key remains in a separate owner-private 0600 file.',
|
||||
].join('\n');
|
||||
|
||||
@@ -27,6 +28,9 @@ interface ClusterCopilotConsoleCliArguments {
|
||||
readonly check: boolean;
|
||||
readonly configFile: string;
|
||||
readonly credentialFile: string;
|
||||
readonly networkBoundary:
|
||||
| 'host-loopback'
|
||||
| 'container-published-loopback';
|
||||
readonly sessionFile: string;
|
||||
readonly port: number;
|
||||
}
|
||||
@@ -71,6 +75,7 @@ export function parseClusterCopilotConsoleCliArguments(
|
||||
let sessionFile: string | undefined;
|
||||
let port = 0;
|
||||
let portSeen = false;
|
||||
let containerPublishedLoopback = false;
|
||||
for (let index = 0; index < argv.length; ) {
|
||||
const current = argv[index];
|
||||
if (current === '--check' && !check) {
|
||||
@@ -78,6 +83,14 @@ export function parseClusterCopilotConsoleCliArguments(
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
current === '--container-published-loopback' &&
|
||||
!containerPublishedLoopback
|
||||
) {
|
||||
containerPublishedLoopback = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const config = argumentValue(argv, index, '--config');
|
||||
if (config) {
|
||||
if (configFile !== undefined) return usageFailure();
|
||||
@@ -121,7 +134,8 @@ export function parseClusterCopilotConsoleCliArguments(
|
||||
configFile === undefined ||
|
||||
credentialFile === undefined ||
|
||||
sessionFile === undefined ||
|
||||
(check && port !== 0)
|
||||
(containerPublishedLoopback && port === 0) ||
|
||||
(!containerPublishedLoopback && check && port !== 0)
|
||||
) {
|
||||
return usageFailure();
|
||||
}
|
||||
@@ -129,6 +143,9 @@ export function parseClusterCopilotConsoleCliArguments(
|
||||
check,
|
||||
configFile,
|
||||
credentialFile,
|
||||
networkBoundary: containerPublishedLoopback
|
||||
? 'container-published-loopback'
|
||||
: 'host-loopback',
|
||||
sessionFile,
|
||||
port,
|
||||
});
|
||||
@@ -178,7 +195,8 @@ async function main(): Promise<void> {
|
||||
component: 'qinglong3-cluster-copilot-console',
|
||||
event: 'preflight_checked',
|
||||
ready: readiness.ready,
|
||||
listenAddress: '127.0.0.1',
|
||||
networkBoundary: parsed.networkBoundary,
|
||||
publishedHostAddress: '127.0.0.1',
|
||||
browserCredential: 'forbidden',
|
||||
clusterCredential: 'server_only',
|
||||
operations: ['inspect', 'output'],
|
||||
@@ -203,6 +221,7 @@ async function main(): Promise<void> {
|
||||
});
|
||||
},
|
||||
}),
|
||||
networkBoundary: parsed.networkBoundary,
|
||||
port: parsed.port,
|
||||
sessionDigest,
|
||||
});
|
||||
@@ -213,7 +232,8 @@ async function main(): Promise<void> {
|
||||
component: 'qinglong3-cluster-copilot-console',
|
||||
event: 'started',
|
||||
origin: server.origin,
|
||||
listenAddress: '127.0.0.1',
|
||||
networkBoundary: parsed.networkBoundary,
|
||||
publishedHostAddress: '127.0.0.1',
|
||||
browserCredential: 'forbidden',
|
||||
clusterCredential: 'server_only',
|
||||
operations: ['inspect', 'output'],
|
||||
|
||||
@@ -39,10 +39,15 @@ export interface ClusterCopilotConsoleExecutor {
|
||||
export interface ClusterCopilotConsoleServerOptions {
|
||||
readonly assets: Readonly<ClusterCopilotConsoleAssets>;
|
||||
readonly executor: ClusterCopilotConsoleExecutor;
|
||||
readonly networkBoundary?: ClusterCopilotConsoleNetworkBoundary;
|
||||
readonly port: number;
|
||||
readonly sessionDigest: Buffer;
|
||||
}
|
||||
|
||||
export type ClusterCopilotConsoleNetworkBoundary =
|
||||
| 'host-loopback'
|
||||
| 'container-published-loopback';
|
||||
|
||||
export interface ClusterCopilotConsoleServer {
|
||||
readonly origin: string;
|
||||
close(): Promise<void>;
|
||||
@@ -297,13 +302,16 @@ function remoteFailure(
|
||||
export async function startClusterCopilotConsoleServer(
|
||||
options: ClusterCopilotConsoleServerOptions,
|
||||
): Promise<Readonly<ClusterCopilotConsoleServer>> {
|
||||
const record = exactObject(options, [
|
||||
'assets',
|
||||
'executor',
|
||||
'port',
|
||||
'sessionDigest',
|
||||
]);
|
||||
const optionKeys = ['assets', 'executor', 'port', 'sessionDigest'];
|
||||
if (Object.hasOwn(options, 'networkBoundary')) {
|
||||
optionKeys.push('networkBoundary');
|
||||
}
|
||||
const record = exactObject(options, optionKeys);
|
||||
const assets = exactObject(record.assets, ['css', 'html', 'javascript']);
|
||||
const networkBoundary =
|
||||
record.networkBoundary === undefined
|
||||
? 'host-loopback'
|
||||
: record.networkBoundary;
|
||||
if (
|
||||
typeof assets.html !== 'string' ||
|
||||
assets.html.length < 1 ||
|
||||
@@ -317,6 +325,10 @@ export async function startClusterCopilotConsoleServer(
|
||||
!Number.isSafeInteger(record.port) ||
|
||||
((record.port as number) !== 0 &&
|
||||
((record.port as number) < 1_024 || (record.port as number) > 65_535)) ||
|
||||
(networkBoundary !== 'host-loopback' &&
|
||||
networkBoundary !== 'container-published-loopback') ||
|
||||
(networkBoundary === 'container-published-loopback' &&
|
||||
(record.port as number) === 0) ||
|
||||
!Buffer.isBuffer(record.sessionDigest) ||
|
||||
(record.sessionDigest as Buffer).byteLength !== 32
|
||||
) {
|
||||
@@ -324,6 +336,8 @@ export async function startClusterCopilotConsoleServer(
|
||||
}
|
||||
const sessionDigest = Buffer.from(record.sessionDigest as Buffer);
|
||||
const executor = record.executor as ClusterCopilotConsoleExecutor;
|
||||
const listenAddress =
|
||||
networkBoundary === 'host-loopback' ? '127.0.0.1' : '0.0.0.0';
|
||||
let expectedOrigin = '';
|
||||
let inFlight = 0;
|
||||
let closed = false;
|
||||
@@ -461,7 +475,7 @@ export async function startClusterCopilotConsoleServer(
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(record.port as number, '127.0.0.1', () => {
|
||||
server.listen(record.port as number, listenAddress, () => {
|
||||
server.off('error', reject);
|
||||
resolve();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { randomBytes } = require('node:crypto');
|
||||
const { request: httpRequest } = require('node:http');
|
||||
const { createServer, request: httpRequest } = require('node:http');
|
||||
const { mkdtemp, mkdir, cp, writeFile } = require('node:fs/promises');
|
||||
const { tmpdir } = require('node:os');
|
||||
const { join, resolve } = require('node:path');
|
||||
@@ -19,6 +19,7 @@ const {
|
||||
normalizeClusterCopilotConsoleReadRequest,
|
||||
} = require('../dist/copilot-console/contracts.js');
|
||||
const {
|
||||
ClusterCopilotConsoleConfigurationError,
|
||||
clusterCopilotConsoleSessionDigest,
|
||||
startClusterCopilotConsoleServer,
|
||||
} = require('../dist/copilot-console/server.js');
|
||||
@@ -165,6 +166,20 @@ async function fixture(execute = async () => inspection()) {
|
||||
};
|
||||
}
|
||||
|
||||
async function unusedPort() {
|
||||
const probe = createServer();
|
||||
await new Promise((resolve, reject) => {
|
||||
probe.once('error', reject);
|
||||
probe.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
const address = probe.address();
|
||||
assert.notEqual(typeof address, 'string');
|
||||
assert.notEqual(address, null);
|
||||
const port = address.port;
|
||||
await new Promise((resolve) => probe.close(resolve));
|
||||
return port;
|
||||
}
|
||||
|
||||
test('normalizes only the two read operations into the shared client contract', () => {
|
||||
assert.deepEqual(
|
||||
clusterCopilotConsoleClientCommand(
|
||||
@@ -244,6 +259,30 @@ test('serves an immutable same-origin shell with a closed browser policy', async
|
||||
assert.equal(javascript.headers['content-type'], 'text/javascript; charset=utf-8');
|
||||
});
|
||||
|
||||
test('allows only an explicit fixed-port container listener behind host loopback publication', async (t) => {
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
await assert.rejects(
|
||||
startClusterCopilotConsoleServer({
|
||||
assets: loadClusterCopilotConsoleAssets(moduleDirectory),
|
||||
executor: { execute: async () => inspection() },
|
||||
networkBoundary: 'container-published-loopback',
|
||||
port: 0,
|
||||
sessionDigest: clusterCopilotConsoleSessionDigest(token),
|
||||
}),
|
||||
ClusterCopilotConsoleConfigurationError,
|
||||
);
|
||||
const server = await startClusterCopilotConsoleServer({
|
||||
assets: loadClusterCopilotConsoleAssets(moduleDirectory),
|
||||
executor: { execute: async () => inspection() },
|
||||
networkBoundary: 'container-published-loopback',
|
||||
port: await unusedPort(),
|
||||
sessionDigest: clusterCopilotConsoleSessionDigest(token),
|
||||
});
|
||||
t.after(() => server.close());
|
||||
assert.match(server.origin, /^http:\/\/127\.0\.0\.1:[0-9]+$/);
|
||||
assert.equal((await request(server.origin)).statusCode, 200);
|
||||
});
|
||||
|
||||
test('keeps the Cluster credential server-side and forwards one exact inspect', async (t) => {
|
||||
const commands = [];
|
||||
const { server, headers } = await fixture(async (command) => {
|
||||
|
||||
@@ -163,8 +163,9 @@ test('CLI exposes deterministic help and a low-sensitive failure surface', async
|
||||
'Usage:',
|
||||
' ql3-copilot-console --config /absolute/client.json --credential /absolute/credential --session /absolute/session [--port=0..65535]',
|
||||
' ql3-copilot-console --check --config /absolute/client.json --credential /absolute/credential --session /absolute/session',
|
||||
' ql3-copilot-console --container-published-loopback --port=1024..65535 --config /absolute/client.json --credential /absolute/credential --session /absolute/session [--check]',
|
||||
'',
|
||||
'The Console binds only 127.0.0.1 and exposes inspect/output reads.',
|
||||
'Native mode binds 127.0.0.1. Container mode requires host-loopback port publication.',
|
||||
'The browser session key remains in a separate owner-private 0600 file.',
|
||||
].join('\n');
|
||||
assert.deepEqual(await runCli(['--help']), {
|
||||
@@ -209,7 +210,8 @@ test('preflight proves private authority and unauthenticated TLS 1.3 readiness',
|
||||
component: 'qinglong3-cluster-copilot-console',
|
||||
event: 'preflight_checked',
|
||||
ready: true,
|
||||
listenAddress: '127.0.0.1',
|
||||
networkBoundary: 'host-loopback',
|
||||
publishedHostAddress: '127.0.0.1',
|
||||
browserCredential: 'forbidden',
|
||||
clusterCredential: 'server_only',
|
||||
operations: ['inspect', 'output'],
|
||||
@@ -249,6 +251,8 @@ test('serve mode starts an ephemeral loopback origin and shuts down cleanly', as
|
||||
assert.match(started.origin, /^http:\/\/127\.0\.0\.1:[0-9]+$/);
|
||||
assert.deepEqual(started.operations, ['inspect', 'output']);
|
||||
assert.equal(started.mutation, false);
|
||||
assert.equal(started.networkBoundary, 'host-loopback');
|
||||
assert.equal(started.publishedHostAddress, '127.0.0.1');
|
||||
const shell = await get(started.origin);
|
||||
assert.equal(shell.statusCode, 200);
|
||||
assert.match(shell.body, /Cluster field console/);
|
||||
@@ -259,3 +263,19 @@ test('serve mode starts an ephemeral loopback origin and shuts down cleanly', as
|
||||
});
|
||||
assert.deepEqual(result, { status: 0, signal: null });
|
||||
});
|
||||
|
||||
test('container mode requires an explicit publish port before any authority read', async () => {
|
||||
const result = await runCli([
|
||||
'--container-published-loopback',
|
||||
'--config',
|
||||
'/private/client.json',
|
||||
'--credential',
|
||||
'/private/credential',
|
||||
'--session',
|
||||
'/private/session',
|
||||
]);
|
||||
assert.equal(result.status, 64);
|
||||
assert.equal(result.stdout, '');
|
||||
assert.match(result.stderr, /container-published-loopback/);
|
||||
assert.doesNotMatch(result.stderr, /\/private\//);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { execFileSync, spawnSync } = require('node:child_process');
|
||||
const { resolve } = require('node:path');
|
||||
|
||||
const IMAGE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/:@-]{0,255}$/u;
|
||||
@@ -63,6 +63,18 @@ function docker(args, options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
function dockerLogs(container) {
|
||||
const result = spawnSync('docker', ['logs', container], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 1024 * 1024,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
if (result.error || result.status !== 0) {
|
||||
fail('published Console logs are unavailable');
|
||||
}
|
||||
return `${result.stdout}${result.stderr}`;
|
||||
}
|
||||
|
||||
function runImage(image, args) {
|
||||
return docker([
|
||||
'run',
|
||||
@@ -301,6 +313,174 @@ child.stdout.on('data', (chunk) => {
|
||||
}
|
||||
}
|
||||
|
||||
function runPublishedConsoleContract(image) {
|
||||
const suffix = `${process.pid}-${Date.now()}`;
|
||||
const network = `ql3-console-live-${suffix}`;
|
||||
const container = `ql3-console-live-${suffix}`;
|
||||
const containerPort = Number(
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
'-e',
|
||||
"const s=require('node:net').createServer();s.listen(0,'127.0.0.1',()=>{process.stdout.write(String(s.address().port));s.close();});",
|
||||
],
|
||||
{ encoding: 'utf8', timeout: 5_000 },
|
||||
),
|
||||
);
|
||||
if (!Number.isSafeInteger(containerPort) || containerPort < 1_024) {
|
||||
fail('published Console test port is invalid');
|
||||
}
|
||||
const source = String.raw`
|
||||
const { spawn } = require('node:child_process');
|
||||
const { statSync, writeFileSync } = require('node:fs');
|
||||
const { rootCertificates } = require('node:tls');
|
||||
const facade = '/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/product-cli/cli.js';
|
||||
const share = '/opt/qinglong/share/ql3-copilot-console';
|
||||
for (const [file, mode] of [['docker-loopback.sh', 0o555], ['verify-release.sh', 0o555], ['README.md', 0o444], ['client-config.example.json', 0o444], ['host-environment.example.json', 0o444]]) {
|
||||
if ((statSync(share + '/' + file).mode & 0o777) !== mode) process.exit(51);
|
||||
}
|
||||
writeFileSync('/tmp/ca.pem', rootCertificates[0], { mode: 0o600 });
|
||||
writeFileSync('/tmp/client.json', JSON.stringify({ schema: 'qinglong/cluster-copilot-client-config@v1', endpoint: 'https://localhost:65535/', servername: 'localhost', caFile: '/tmp/ca.pem', requestTimeoutMs: 1000 }), { mode: 0o600 });
|
||||
writeFileSync('/tmp/credential', 'ql3c_console_' + Buffer.alloc(32, 7).toString('base64url'), { mode: 0o600 });
|
||||
writeFileSync('/tmp/session', Buffer.alloc(32, 11).toString('base64url'), { mode: 0o600 });
|
||||
const child = spawn(process.execPath, [facade, 'copilot-console', '--container-published-loopback', '--port=${containerPort}', '--config', '/tmp/client.json', '--credential', '/tmp/credential', '--session', '/tmp/session'], { stdio: 'inherit' });
|
||||
child.once('exit', (code, signal) => {
|
||||
if (signal) process.kill(process.pid, signal);
|
||||
else process.exit(code ?? 1);
|
||||
});
|
||||
process.once('SIGTERM', () => child.kill('SIGTERM'));
|
||||
process.once('SIGINT', () => child.kill('SIGINT'));
|
||||
`;
|
||||
let createdNetwork = false;
|
||||
let createdContainer = false;
|
||||
try {
|
||||
docker(['network', 'create', '--driver', 'bridge', network]);
|
||||
createdNetwork = true;
|
||||
docker([
|
||||
'run',
|
||||
'--detach',
|
||||
'--name',
|
||||
container,
|
||||
'--read-only',
|
||||
'--network',
|
||||
network,
|
||||
'--cap-drop',
|
||||
'ALL',
|
||||
'--security-opt',
|
||||
'no-new-privileges',
|
||||
'--user',
|
||||
'10001:10001',
|
||||
'--pids-limit',
|
||||
'32',
|
||||
'--memory',
|
||||
'192m',
|
||||
'--cpus',
|
||||
'0.25',
|
||||
'--stop-timeout',
|
||||
'3',
|
||||
'--tmpfs',
|
||||
'/tmp:rw,noexec,nosuid,nodev,size=8m,mode=700,uid=10001,gid=10001',
|
||||
'--publish',
|
||||
`127.0.0.1:${containerPort}:${containerPort}/tcp`,
|
||||
'--entrypoint',
|
||||
'node',
|
||||
image,
|
||||
'-e',
|
||||
source,
|
||||
]);
|
||||
createdContainer = true;
|
||||
|
||||
const waitArray = new Int32Array(new SharedArrayBuffer(4));
|
||||
let logs = '';
|
||||
for (let attempt = 0; attempt < 200; attempt += 1) {
|
||||
logs = dockerLogs(container);
|
||||
if (logs.includes('"event":"started"')) break;
|
||||
Atomics.wait(waitArray, 0, 0, 25);
|
||||
}
|
||||
const startedLine = logs
|
||||
.split('\n')
|
||||
.find((line) => line.includes('"event":"started"'));
|
||||
if (!startedLine) {
|
||||
const state = JSON.parse(docker(['inspect', container]))[0]?.State;
|
||||
let terminalCode = 'absent';
|
||||
for (const line of logs.trim().split('\n').reverse()) {
|
||||
try {
|
||||
const fact = JSON.parse(line);
|
||||
terminalCode = fact.code ?? fact.event ?? 'unknown';
|
||||
break;
|
||||
} catch {}
|
||||
}
|
||||
fail(
|
||||
`published Console did not start (running=${String(state?.Running)}, exit=${String(state?.ExitCode)}, code=${terminalCode})`,
|
||||
);
|
||||
}
|
||||
let started;
|
||||
try {
|
||||
started = JSON.parse(startedLine);
|
||||
} catch {
|
||||
fail('published Console start fact is invalid');
|
||||
}
|
||||
if (
|
||||
started?.origin !== `http://127.0.0.1:${containerPort}` ||
|
||||
started?.networkBoundary !== 'container-published-loopback' ||
|
||||
started?.publishedHostAddress !== '127.0.0.1'
|
||||
) {
|
||||
fail('published Console boundary fact drifted');
|
||||
}
|
||||
|
||||
const published = docker([
|
||||
'port',
|
||||
container,
|
||||
`${containerPort}/tcp`,
|
||||
]).trim();
|
||||
const publishedMatch = /^127\.0\.0\.1:([1-9][0-9]{0,4})$/u.exec(
|
||||
published,
|
||||
);
|
||||
if (!publishedMatch) fail('published Console escaped host loopback');
|
||||
const origin = `http://127.0.0.1:${publishedMatch[1]}`;
|
||||
const probe = execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
'-e',
|
||||
"require('node:http').get(process.argv[1],(r)=>{const c=[];r.on('data',(x)=>c.push(x));r.on('end',()=>{const b=Buffer.concat(c).toString('utf8');if(r.statusCode!==200||!b.includes('Cluster field console'))process.exit(2);process.stdout.write(JSON.stringify({status:r.statusCode,assets:b.includes('/app.css')&&b.includes('/app.js')}));});}).on('error',()=>process.exit(3));",
|
||||
origin,
|
||||
],
|
||||
{ encoding: 'utf8', timeout: 5_000 },
|
||||
);
|
||||
const probeFact = JSON.parse(probe);
|
||||
if (probeFact.status !== 200 || probeFact.assets !== true) {
|
||||
fail('published Console host read drifted');
|
||||
}
|
||||
|
||||
const inspected = JSON.parse(docker(['inspect', container]))[0];
|
||||
const binding =
|
||||
inspected?.HostConfig?.PortBindings?.[`${containerPort}/tcp`]?.[0];
|
||||
if (
|
||||
inspected?.HostConfig?.ReadonlyRootfs !== true ||
|
||||
inspected?.HostConfig?.NetworkMode !== network ||
|
||||
binding?.HostIp !== '127.0.0.1' ||
|
||||
inspected?.HostConfig?.Privileged !== false ||
|
||||
!inspected?.HostConfig?.CapDrop?.includes('ALL')
|
||||
) {
|
||||
fail('published Console container authority drifted');
|
||||
}
|
||||
} finally {
|
||||
if (createdContainer) {
|
||||
try {
|
||||
docker(['stop', '--time', '3', container]);
|
||||
} catch {}
|
||||
try {
|
||||
docker(['rm', '--force', container]);
|
||||
} catch {}
|
||||
}
|
||||
if (createdNetwork) {
|
||||
try {
|
||||
docker(['network', 'rm', network]);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (process.env.QL3_CLUSTER_ADMIN_PRODUCT_LIVE !== '1') {
|
||||
fail('QL3_CLUSTER_ADMIN_PRODUCT_LIVE=1 is required');
|
||||
@@ -341,6 +521,7 @@ function main() {
|
||||
if (version !== '3.0.0-alpha.0') fail('product version contract drifted');
|
||||
runOperatorContextContract(image);
|
||||
runConsoleContract(image);
|
||||
runPublishedConsoleContract(image);
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
@@ -355,6 +536,8 @@ function main() {
|
||||
contextReadiness: true,
|
||||
consoleLoopback: true,
|
||||
consoleAssets: true,
|
||||
consolePublishedHostAddress: '127.0.0.1',
|
||||
consoleDistributionEmbedded: true,
|
||||
isolation: Object.freeze({
|
||||
readOnlyRoot: true,
|
||||
network: 'none',
|
||||
|
||||
@@ -106,7 +106,9 @@ function auditClusterCopilotConsole(options = {}) {
|
||||
'credential',
|
||||
]);
|
||||
expectFragments(CONSOLE_ROOT + '/server.ts', [
|
||||
"server.listen(record.port as number, '127.0.0.1'",
|
||||
"networkBoundary === 'host-loopback' ? '127.0.0.1' : '0.0.0.0'",
|
||||
"networkBoundary === 'container-published-loopback'",
|
||||
'server.listen(record.port as number, listenAddress',
|
||||
'request.headers.origin !== expectedOrigin',
|
||||
"request.headers.host !== expectedOrigin.slice('http://'.length)",
|
||||
'maximumConcurrentRequests: 2',
|
||||
@@ -117,7 +119,6 @@ function auditClusterCopilotConsole(options = {}) {
|
||||
"'cache-control': 'no-store'",
|
||||
]);
|
||||
rejectFragments(CONSOLE_ROOT + '/server.ts', [
|
||||
"'0.0.0.0'",
|
||||
'createSecureServer',
|
||||
'WebSocket',
|
||||
'set-cookie',
|
||||
@@ -133,6 +134,8 @@ function auditClusterCopilotConsole(options = {}) {
|
||||
"'private'",
|
||||
'validateClusterCopilotClientCredentialFile',
|
||||
"clusterCredential: 'server_only'",
|
||||
"networkBoundary: parsed.networkBoundary",
|
||||
"publishedHostAddress: '127.0.0.1'",
|
||||
"operations: ['inspect', 'output']",
|
||||
'mutation: false',
|
||||
]);
|
||||
@@ -195,8 +198,12 @@ function auditClusterCopilotConsole(options = {}) {
|
||||
"started.event !== 'started'",
|
||||
"body.includes('Cluster field console')",
|
||||
'runConsoleContract(image);',
|
||||
'function runPublishedConsoleContract(image)',
|
||||
'runPublishedConsoleContract(image);',
|
||||
'consoleLoopback: true',
|
||||
'consoleAssets: true',
|
||||
"consolePublishedHostAddress: '127.0.0.1'",
|
||||
'consoleDistributionEmbedded: true',
|
||||
]);
|
||||
|
||||
let manifest;
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const FILES = Object.freeze({
|
||||
launcher: 'deploy/console/ql3-cluster-copilot/docker-loopback.sh',
|
||||
verifier: 'deploy/console/ql3-cluster-copilot/verify-release.sh',
|
||||
environment:
|
||||
'deploy/console/ql3-cluster-copilot/host-environment.example.json',
|
||||
image: 'deploy/containers/ql3-cluster-admin/Dockerfile',
|
||||
workflow: '.github/workflows/ql3-image-release.yml',
|
||||
cli: 'packages/ql3-cluster-admin/src/copilot-console/cli.ts',
|
||||
server: 'packages/ql3-cluster-admin/src/copilot-console/server.ts',
|
||||
});
|
||||
|
||||
function finding(code, target, detail) {
|
||||
return Object.freeze({ code, target, detail });
|
||||
}
|
||||
|
||||
function auditClusterCopilotConsoleDistribution(options = {}) {
|
||||
const root = options.root || path.resolve(__dirname, '..');
|
||||
const readFile =
|
||||
options.readFile ||
|
||||
((relativePath) => fs.readFileSync(path.join(root, relativePath), 'utf8'));
|
||||
const findings = [];
|
||||
const source = {};
|
||||
for (const [name, relativePath] of Object.entries(FILES)) {
|
||||
try {
|
||||
source[name] = readFile(relativePath);
|
||||
} catch (error) {
|
||||
findings.push(
|
||||
finding(
|
||||
'QL3_COPILOT_CONSOLE_DISTRIBUTION_FILE_MISSING',
|
||||
relativePath,
|
||||
error instanceof Error ? error.name : 'Error',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const requireFragments = (name, fragments, code) => {
|
||||
const contents = source[name];
|
||||
if (typeof contents !== 'string') return;
|
||||
for (const fragment of fragments) {
|
||||
if (!contents.includes(fragment)) {
|
||||
findings.push(finding(code, FILES[name], fragment));
|
||||
}
|
||||
}
|
||||
};
|
||||
const rejectFragments = (name, fragments, code) => {
|
||||
const contents = source[name];
|
||||
if (typeof contents !== 'string') return;
|
||||
for (const fragment of fragments) {
|
||||
if (contents.includes(fragment)) {
|
||||
findings.push(finding(code, FILES[name], fragment));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
requireFragments(
|
||||
'launcher',
|
||||
[
|
||||
'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"',
|
||||
'--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"',
|
||||
'--publish "127.0.0.1:$port:$port/tcp"',
|
||||
'--container-published-loopback',
|
||||
'bridge|default|host|none) fail',
|
||||
'compact)',
|
||||
'memory=192m',
|
||||
'standard)',
|
||||
'memory=512m',
|
||||
],
|
||||
'QL3_COPILOT_CONSOLE_LAUNCHER_CONTRACT_DRIFT',
|
||||
);
|
||||
rejectFragments(
|
||||
'launcher',
|
||||
['--privileged', '--network host', '/var/run/docker.sock', '--pull always'],
|
||||
'QL3_COPILOT_CONSOLE_LAUNCHER_AUTHORITY_WIDENED',
|
||||
);
|
||||
|
||||
requireFragments(
|
||||
'verifier',
|
||||
[
|
||||
'qinglong3-cluster-admin@sha256:',
|
||||
'cosign verify',
|
||||
'--certificate-identity "$certificate_identity"',
|
||||
'--certificate-oidc-issuer https://token.actions.githubusercontent.com',
|
||||
'gh attestation verify "oci://$image"',
|
||||
'--signer-workflow "$workflow"',
|
||||
'--source-digest "$source_revision"',
|
||||
'--source-ref "$source_ref"',
|
||||
'--deny-self-hosted-runners',
|
||||
'--bundle-from-oci',
|
||||
'https://cyclonedx.org/bom',
|
||||
'https://qinglong.dev/attestations/image-os-vulnerability/v1',
|
||||
],
|
||||
'QL3_CLUSTER_ADMIN_RELEASE_VERIFIER_DRIFT',
|
||||
);
|
||||
rejectFragments(
|
||||
'verifier',
|
||||
[':latest', 'refs/heads/', '--insecure-ignore-tlog', '--certificate-identity-regexp'],
|
||||
'QL3_CLUSTER_ADMIN_RELEASE_VERIFIER_WIDENED',
|
||||
);
|
||||
|
||||
let environment;
|
||||
try {
|
||||
environment = JSON.parse(source.environment);
|
||||
} catch (error) {
|
||||
if (typeof source.environment === 'string') {
|
||||
findings.push(
|
||||
finding(
|
||||
'QL3_COPILOT_CONSOLE_HOST_ENVIRONMENT_INVALID',
|
||||
FILES.environment,
|
||||
error instanceof Error ? error.name : 'Error',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
const expectedEnvironment = {
|
||||
QL3_COPILOT_CONSOLE_IMAGE:
|
||||
'ghcr.io/replace-owner/qinglong3-cluster-admin@sha256:' + '0'.repeat(64),
|
||||
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',
|
||||
};
|
||||
if (
|
||||
environment &&
|
||||
JSON.stringify(environment) !== JSON.stringify(expectedEnvironment)
|
||||
) {
|
||||
findings.push(
|
||||
finding(
|
||||
'QL3_COPILOT_CONSOLE_HOST_ENVIRONMENT_INVALID',
|
||||
FILES.environment,
|
||||
'exact digest, private-root, named-network, port and resource-class keys are required',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
requireFragments(
|
||||
'image',
|
||||
[
|
||||
'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/host-environment.example.json',
|
||||
'share/ql3-copilot-console/host-environment.example.json',
|
||||
],
|
||||
'QL3_COPILOT_CONSOLE_IMAGE_DISTRIBUTION_DRIFT',
|
||||
);
|
||||
requireFragments(
|
||||
'workflow',
|
||||
[
|
||||
'image: admin',
|
||||
'image_arch: amd64',
|
||||
'image_arch: arm64',
|
||||
'cosign sign --yes "${IMAGE}@${DIGEST}"',
|
||||
'predicate-type: https://qinglong.dev/attestations/image-os-vulnerability/v1',
|
||||
'gh attestation verify "oci://${IMAGE}@${DIGEST}"',
|
||||
'--predicate-type "https://cyclonedx.org/bom"',
|
||||
'--deny-self-hosted-runners',
|
||||
'--bundle-from-oci',
|
||||
'Promote only the verified digest to immutable release tags',
|
||||
],
|
||||
'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT',
|
||||
);
|
||||
requireFragments(
|
||||
'cli',
|
||||
[
|
||||
"'container-published-loopback'",
|
||||
"publishedHostAddress: '127.0.0.1'",
|
||||
'(containerPublishedLoopback && port === 0)',
|
||||
],
|
||||
'QL3_COPILOT_CONSOLE_NETWORK_BOUNDARY_DRIFT',
|
||||
);
|
||||
requireFragments(
|
||||
'server',
|
||||
[
|
||||
"networkBoundary === 'host-loopback' ? '127.0.0.1' : '0.0.0.0'",
|
||||
"networkBoundary === 'container-published-loopback'",
|
||||
'server.listen(record.port as number, listenAddress',
|
||||
],
|
||||
'QL3_COPILOT_CONSOLE_NETWORK_BOUNDARY_DRIFT',
|
||||
);
|
||||
|
||||
const kubernetesRoot = path.join(root, 'deploy/kubernetes');
|
||||
const pending = [kubernetesRoot];
|
||||
while (pending.length > 0) {
|
||||
const directory = pending.pop();
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const absolute = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) pending.push(absolute);
|
||||
else if (
|
||||
entry.isFile() &&
|
||||
/\.ya?ml$/u.test(entry.name) &&
|
||||
fs.readFileSync(absolute, 'utf8').includes('ql3-copilot-console')
|
||||
) {
|
||||
findings.push(
|
||||
finding(
|
||||
'QL3_COPILOT_CONSOLE_KUBERNETES_RESIDENT',
|
||||
path.relative(root, absolute),
|
||||
'workstation Console must remain outside the Cluster workload graph',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'cluster-copilot-console-distribution',
|
||||
artifact: 'signed-admin-oci',
|
||||
architectures: Object.freeze(['amd64', 'arm64']),
|
||||
hostPublication: '127.0.0.1',
|
||||
kubernetesResident: false,
|
||||
additionalWorkspacePackages: 0,
|
||||
findings: Object.freeze(findings),
|
||||
compatible: findings.length === 0,
|
||||
});
|
||||
}
|
||||
|
||||
function main() {
|
||||
const report = auditClusterCopilotConsoleDistribution();
|
||||
process.stdout.write(JSON.stringify(report) + '\n');
|
||||
if (!report.compatible) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = { auditClusterCopilotConsoleDistribution };
|
||||
@@ -49,7 +49,7 @@ test('fails closed before Docker without explicit opt-in', () => {
|
||||
assert.equal(result.stderr.includes('spawn'), false);
|
||||
});
|
||||
|
||||
test('binds the live image gate to loopback Console assets and shutdown', () => {
|
||||
test('binds the live image gate to native and container-published loopback', () => {
|
||||
const source = fs.readFileSync(script, 'utf8');
|
||||
assert.match(source, /function runConsoleContract\(image\)/);
|
||||
assert.match(source, /\[facade, 'copilot-console'/);
|
||||
@@ -57,4 +57,12 @@ test('binds the live image gate to loopback Console assets and shutdown', () =>
|
||||
assert.match(source, /runConsoleContract\(image\);/);
|
||||
assert.match(source, /consoleLoopback: true/);
|
||||
assert.match(source, /consoleAssets: true/);
|
||||
assert.match(source, /function runPublishedConsoleContract\(image\)/);
|
||||
assert.match(
|
||||
source,
|
||||
/127\.0\.0\.1:\$\{containerPort\}:\$\{containerPort\}\/tcp/,
|
||||
);
|
||||
assert.match(source, /runPublishedConsoleContract\(image\);/);
|
||||
assert.match(source, /consolePublishedHostAddress: '127\.0\.0\.1'/);
|
||||
assert.match(source, /consoleDistributionEmbedded: true/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '../..');
|
||||
const verifier = path.join(
|
||||
ROOT,
|
||||
'deploy/console/ql3-cluster-copilot/verify-release.sh',
|
||||
);
|
||||
const image = `ghcr.io/example/qinglong3-cluster-admin@sha256:${'b'.repeat(64)}`;
|
||||
const revision = 'c'.repeat(40);
|
||||
|
||||
function fixture(t) {
|
||||
const directory = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-admin-verifier-')),
|
||||
);
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const bin = path.join(directory, 'bin');
|
||||
const capture = path.join(directory, 'calls');
|
||||
fs.mkdirSync(bin, { mode: 0o700 });
|
||||
for (const command of ['cosign', 'gh']) {
|
||||
fs.writeFileSync(
|
||||
path.join(bin, command),
|
||||
`#!/bin/sh\nprintf '${command}\\n' >> "$QL3_TEST_VERIFY_CALLS"\nprintf 'arg=%s\\n' "$@" >> "$QL3_TEST_VERIFY_CALLS"\n`,
|
||||
{ mode: 0o700 },
|
||||
);
|
||||
}
|
||||
return {
|
||||
capture,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${bin}:${process.env.PATH ?? ''}`,
|
||||
QL3_TEST_VERIFY_CALLS: capture,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function invoke(args, env) {
|
||||
return spawnSync(verifier, args, {
|
||||
cwd: ROOT,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
test('verifies one signature and three digest-bound GitHub attestations', (t) => {
|
||||
assert.equal(fs.statSync(verifier).mode & 0o777, 0o755);
|
||||
const value = fixture(t);
|
||||
const result = invoke(
|
||||
[image, 'example/qinglong', revision, 'refs/tags/v3.0.0-alpha.1'],
|
||||
value.env,
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.deepEqual(JSON.parse(result.stdout), {
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-cluster-admin-release-verifier',
|
||||
signature: true,
|
||||
provenance: true,
|
||||
sbom: true,
|
||||
osVulnerabilityEvidence: true,
|
||||
compatible: true,
|
||||
});
|
||||
const calls = fs.readFileSync(value.capture, 'utf8');
|
||||
assert.equal((calls.match(/^cosign$/gmu) ?? []).length, 1);
|
||||
assert.equal((calls.match(/^gh$/gmu) ?? []).length, 3);
|
||||
for (const required of [
|
||||
'arg=--certificate-identity',
|
||||
'arg=https://github.com/example/qinglong/.github/workflows/ql3-image-release.yml@refs/tags/v3.0.0-alpha.1',
|
||||
'arg=--certificate-oidc-issuer',
|
||||
'arg=https://token.actions.githubusercontent.com',
|
||||
`arg=${image}`,
|
||||
`arg=oci://${image}`,
|
||||
'arg=--repo',
|
||||
'arg=example/qinglong',
|
||||
'arg=--signer-workflow',
|
||||
'arg=example/qinglong/.github/workflows/ql3-image-release.yml',
|
||||
'arg=--source-digest',
|
||||
`arg=${revision}`,
|
||||
'arg=--source-ref',
|
||||
'arg=refs/tags/v3.0.0-alpha.1',
|
||||
'arg=https://cyclonedx.org/bom',
|
||||
'arg=https://qinglong.dev/attestations/image-os-vulnerability/v1',
|
||||
'arg=--deny-self-hosted-runners',
|
||||
'arg=--bundle-from-oci',
|
||||
]) {
|
||||
assert.match(calls, new RegExp(`^${required.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'mu'));
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects mutable or source-unbound inputs before invoking trust tools', (t) => {
|
||||
const value = fixture(t);
|
||||
for (const args of [
|
||||
['ghcr.io/example/qinglong3-cluster-admin:latest', 'example/qinglong', revision, 'refs/tags/v3.0.0'],
|
||||
[image, 'other/qinglong', revision, 'refs/tags/v3.0.0'],
|
||||
[image, 'example/qinglong', 'short', 'refs/tags/v3.0.0'],
|
||||
[image, 'example/qinglong', revision, 'refs/heads/next'],
|
||||
]) {
|
||||
const rejected = invoke(args, value.env);
|
||||
assert.equal(rejected.status, 78);
|
||||
assert.equal(rejected.stdout, '');
|
||||
assert.deepEqual(JSON.parse(rejected.stderr), {
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-cluster-admin-release-verifier',
|
||||
event: 'verification_failed',
|
||||
});
|
||||
assert.equal(fs.existsSync(value.capture), false);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const {
|
||||
auditClusterCopilotConsoleDistribution,
|
||||
} = require('../../scripts/ql3-cluster-copilot-console-distribution-audit.cjs');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '../..');
|
||||
|
||||
function intercept(target, transform) {
|
||||
return (relativePath) => {
|
||||
const source = fs.readFileSync(path.join(ROOT, relativePath), 'utf8');
|
||||
return relativePath === target ? transform(source) : source;
|
||||
};
|
||||
}
|
||||
|
||||
test('accepts the signed multi-architecture Admin OCI workstation distribution', () => {
|
||||
assert.deepEqual(auditClusterCopilotConsoleDistribution({ root: ROOT }), {
|
||||
schemaVersion: 1,
|
||||
component: 'cluster-copilot-console-distribution',
|
||||
artifact: 'signed-admin-oci',
|
||||
architectures: ['amd64', 'arm64'],
|
||||
hostPublication: '127.0.0.1',
|
||||
kubernetesResident: false,
|
||||
additionalWorkspacePackages: 0,
|
||||
findings: [],
|
||||
compatible: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects remote publication and weakened image runtime authority', () => {
|
||||
for (const transform of [
|
||||
(source) => source.replace('127.0.0.1:$port:$port/tcp', '0.0.0.0:$port:$port/tcp'),
|
||||
(source) => source.replace('--cap-drop ALL', '--privileged'),
|
||||
(source) => source.replace('--network "$network"', '--network host'),
|
||||
]) {
|
||||
const report = auditClusterCopilotConsoleDistribution({
|
||||
root: ROOT,
|
||||
readFile: intercept(
|
||||
'deploy/console/ql3-cluster-copilot/docker-loopback.sh',
|
||||
transform,
|
||||
),
|
||||
});
|
||||
assert.equal(report.compatible, false);
|
||||
assert.ok(
|
||||
report.findings.some(({ code }) =>
|
||||
code.startsWith('QL3_COPILOT_CONSOLE_LAUNCHER_'),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects verifier, embedded artifact and release workflow drift', () => {
|
||||
const fixtures = [
|
||||
[
|
||||
'deploy/console/ql3-cluster-copilot/verify-release.sh',
|
||||
(source) => source.replace('--deny-self-hosted-runners', ''),
|
||||
'QL3_CLUSTER_ADMIN_RELEASE_VERIFIER_DRIFT',
|
||||
],
|
||||
[
|
||||
'deploy/containers/ql3-cluster-admin/Dockerfile',
|
||||
(source) => source.replace('COPY --chmod=0555', 'COPY --chmod=0777'),
|
||||
'QL3_COPILOT_CONSOLE_IMAGE_DISTRIBUTION_DRIFT',
|
||||
],
|
||||
[
|
||||
'.github/workflows/ql3-image-release.yml',
|
||||
(source) => source.replace(
|
||||
'Promote only the verified digest to immutable release tags',
|
||||
'Promote mutable release tags',
|
||||
),
|
||||
'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT',
|
||||
],
|
||||
];
|
||||
for (const [target, transform, code] of fixtures) {
|
||||
const report = auditClusterCopilotConsoleDistribution({
|
||||
root: ROOT,
|
||||
readFile: intercept(target, transform),
|
||||
});
|
||||
assert.equal(report.compatible, false);
|
||||
assert.ok(report.findings.some((finding) => finding.code === code));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '../..');
|
||||
const launcher = path.join(
|
||||
ROOT,
|
||||
'deploy/console/ql3-cluster-copilot/docker-loopback.sh',
|
||||
);
|
||||
const image = `ghcr.io/example/qinglong3-cluster-admin@sha256:${'a'.repeat(64)}`;
|
||||
|
||||
function fixture(t) {
|
||||
const directory = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-copilot-console-launcher-')),
|
||||
);
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const privateRoot = path.join(directory, 'private');
|
||||
const bin = path.join(directory, 'bin');
|
||||
fs.mkdirSync(privateRoot, { mode: 0o700 });
|
||||
fs.mkdirSync(bin, { mode: 0o700 });
|
||||
const capture = path.join(directory, 'docker-args');
|
||||
fs.writeFileSync(
|
||||
path.join(bin, 'docker'),
|
||||
'#!/bin/sh\nprintf \'%s\\n\' "$@" > "$QL3_TEST_DOCKER_ARGS"\n',
|
||||
{ mode: 0o700 },
|
||||
);
|
||||
return {
|
||||
privateRoot: fs.realpathSync(privateRoot),
|
||||
capture,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${bin}:${process.env.PATH ?? ''}`,
|
||||
QL3_TEST_DOCKER_ARGS: capture,
|
||||
QL3_COPILOT_CONSOLE_IMAGE: image,
|
||||
QL3_COPILOT_CONSOLE_PRIVATE_ROOT: fs.realpathSync(privateRoot),
|
||||
QL3_COPILOT_CONSOLE_NETWORK: 'qinglong3-console-egress',
|
||||
QL3_COPILOT_CONSOLE_PORT: '5701',
|
||||
QL3_COPILOT_CONSOLE_RESOURCE_CLASS: 'compact',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function invoke(mode, env) {
|
||||
return spawnSync(launcher, [mode], {
|
||||
cwd: ROOT,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
test('runs compact preflight without opening or publishing the Console', (t) => {
|
||||
assert.equal(fs.statSync(launcher).mode & 0o777, 0o755);
|
||||
const value = fixture(t);
|
||||
const result = invoke('check', value.env);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stdout, '');
|
||||
assert.equal(result.stderr, '');
|
||||
assert.deepEqual(
|
||||
fs.readFileSync(value.capture, 'utf8').trimEnd().split('\n'),
|
||||
[
|
||||
'run',
|
||||
'--rm',
|
||||
'--pull',
|
||||
'never',
|
||||
'--init',
|
||||
'--read-only',
|
||||
'--network',
|
||||
'qinglong3-console-egress',
|
||||
'--cap-drop',
|
||||
'ALL',
|
||||
'--security-opt',
|
||||
'no-new-privileges',
|
||||
'--user',
|
||||
'10001:10001',
|
||||
'--pids-limit',
|
||||
'32',
|
||||
'--memory',
|
||||
'192m',
|
||||
'--cpus',
|
||||
'0.25',
|
||||
'--stop-timeout',
|
||||
'3',
|
||||
'--tmpfs',
|
||||
'/tmp:rw,noexec,nosuid,nodev,size=8m,mode=700,uid=10001,gid=10001',
|
||||
'--mount',
|
||||
`type=bind,src=${value.privateRoot},dst=/var/run/secrets/qinglong3/copilot-console,readonly`,
|
||||
image,
|
||||
'copilot-console',
|
||||
'--container-published-loopback',
|
||||
'--port=5701',
|
||||
'--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',
|
||||
'--check',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes standard serve only on host loopback', (t) => {
|
||||
const value = fixture(t);
|
||||
const result = invoke('serve', {
|
||||
...value.env,
|
||||
QL3_COPILOT_CONSOLE_RESOURCE_CLASS: 'standard',
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const args = fs.readFileSync(value.capture, 'utf8').trimEnd().split('\n');
|
||||
assert.equal(args.includes('--check'), false);
|
||||
assert.equal(args[args.indexOf('--memory') + 1], '512m');
|
||||
assert.equal(args[args.indexOf('--cpus') + 1], '1');
|
||||
assert.equal(args[args.indexOf('--pids-limit') + 1], '64');
|
||||
assert.equal(
|
||||
args[args.indexOf('--publish') + 1],
|
||||
'127.0.0.1:5701:5701/tcp',
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects mutable, ambient and malformed host inputs before Docker', (t) => {
|
||||
const value = fixture(t);
|
||||
for (const environment of [
|
||||
{ ...value.env, QL3_COPILOT_CONSOLE_IMAGE: 'ghcr.io/example/admin:latest' },
|
||||
{ ...value.env, QL3_COPILOT_CONSOLE_NETWORK: 'host' },
|
||||
{ ...value.env, QL3_COPILOT_CONSOLE_PORT: '80' },
|
||||
{ ...value.env, QL3_COPILOT_CONSOLE_PRIVATE_ROOT: `${value.privateRoot}:rw` },
|
||||
{ ...value.env, QL3_COPILOT_CONSOLE_RESOURCE_CLASS: 'unbounded' },
|
||||
]) {
|
||||
const rejected = invoke('serve', environment);
|
||||
assert.equal(rejected.status, 78);
|
||||
assert.equal(rejected.stdout, '');
|
||||
assert.deepEqual(JSON.parse(rejected.stderr), {
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-cluster-copilot-console-launcher',
|
||||
event: 'launch_failed',
|
||||
});
|
||||
assert.equal(fs.existsSync(value.capture), false);
|
||||
assert.doesNotMatch(rejected.stderr, /latest|host|private|unbounded/);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user