feat(ql3): ship bounded legacy panel console

This commit is contained in:
whyour
2026-09-02 13:34:46 +08:00
parent ca41640794
commit 1b223ff2ad
14 changed files with 1144 additions and 63 deletions
+39 -2
View File
@@ -132,6 +132,21 @@ jobs:
run: pnpm install --frozen-lockfile --ignore-scripts run: pnpm install --frozen-lockfile --ignore-scripts
- name: Build the capability-gated legacy panel source - name: Build the capability-gated legacy panel source
run: pnpm build:front run: pnpm build:front
- name: Materialize and audit the bounded legacy panel artifact
run: >-
node scripts/ql3-legacy-panel-bundle.cjs
--source="${GITHUB_WORKSPACE}/static/dist"
--output="${RUNNER_TEMP}/ql3-legacy-panel"
- name: Upload the bounded legacy panel artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ql3-legacy-panel-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/ql3-legacy-panel
if-no-files-found: error
retention-days: 14
compression-level: 0
overwrite: false
include-hidden-files: false
service-manager-bridge: service-manager-bridge:
name: systemd/OpenRC dual-authority bridge name: systemd/OpenRC dual-authority bridge
@@ -373,6 +388,7 @@ jobs:
local-image: local-image:
name: Local application image (${{ matrix.image_arch }}) name: Local application image (${{ matrix.image_arch }})
needs: legacy-panel-compatibility
runs-on: ${{ matrix.runner }} runs-on: ${{ matrix.runner }}
strategy: strategy:
fail-fast: false fail-fast: false
@@ -386,6 +402,11 @@ jobs:
image_arch: arm64 image_arch: arm64
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- name: Download the bounded legacy panel artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ql3-legacy-panel-${{ github.run_id }}-${{ github.run_attempt }}
path: .ql3-panel-dist
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
with: with:
version: '8.3.1' version: '8.3.1'
@@ -396,6 +417,8 @@ jobs:
cache-dependency-path: pnpm-lock.yaml cache-dependency-path: pnpm-lock.yaml
- name: Verify native runner architecture - name: Verify native runner architecture
run: node -e "if (process.arch !== '${{ matrix.node_arch }}') throw new Error('unexpected architecture ' + process.arch)" run: node -e "if (process.arch !== '${{ matrix.node_arch }}') throw new Error('unexpected architecture ' + process.arch)"
- name: Audit the downloaded legacy panel closure
run: node scripts/ql3-legacy-panel-bundle.cjs --audit="${GITHUB_WORKSPACE}/.ql3-panel-dist"
- name: Install workspace dependencies without lifecycle scripts - name: Install workspace dependencies without lifecycle scripts
run: pnpm install --frozen-lockfile --ignore-scripts run: pnpm install --frozen-lockfile --ignore-scripts
- name: Audit immutable local image inputs - name: Audit immutable local image inputs
@@ -515,10 +538,10 @@ jobs:
- name: Verify the opt-in Local Console identity - name: Verify the opt-in Local Console identity
env: env:
CONSOLE_IMAGE: qinglong3-local-console:ci-${{ matrix.image_arch }} CONSOLE_IMAGE: qinglong3-local-console:ci-${{ matrix.image_arch }}
EXPECTED: ${{ matrix.image_arch }} 65532:65532 edge-application-api,standalone-application-api offline-loopback EXPECTED: ${{ matrix.image_arch }} 65532:65532 edge-application-api,standalone-application-api offline-loopback legacy-capability-gated@v1 256 13631488
run: | run: |
set -euo pipefail set -euo pipefail
actual="$(docker image inspect --format '{{.Architecture}} {{.Config.User}} {{index .Config.Labels "io.qinglong.profile"}} {{index .Config.Labels "io.qinglong.local.console"}}' "${CONSOLE_IMAGE}")" actual="$(docker image inspect --format '{{.Architecture}} {{.Config.User}} {{index .Config.Labels "io.qinglong.profile"}} {{index .Config.Labels "io.qinglong.local.console"}} {{index .Config.Labels "io.qinglong.local.panel"}} {{index .Config.Labels "io.qinglong.local.panel-max-files"}} {{index .Config.Labels "io.qinglong.local.panel-max-bytes"}}' "${CONSOLE_IMAGE}")"
if [[ "${actual}" != "${EXPECTED}" ]]; then if [[ "${actual}" != "${EXPECTED}" ]]; then
echo "unexpected Local Console image contract: ${actual}" >&2 echo "unexpected Local Console image contract: ${actual}" >&2
exit 1 exit 1
@@ -565,6 +588,20 @@ jobs:
"${CONSOLE_IMAGE}" "${CONSOLE_IMAGE}"
scripts/ql3-local-console-image-inventory.cjs scripts/ql3-local-console-image-inventory.cjs
--inventory-root=/opt/qinglong/node_modules --inventory-root=/opt/qinglong/node_modules
- name: Audit the installed legacy panel closure
env:
CONSOLE_IMAGE: qinglong3-local-console:ci-${{ matrix.image_arch }}
run: >-
docker run --rm --read-only
--network none
--cap-drop ALL
--security-opt no-new-privileges
--volume "${{ github.workspace }}:/audit:ro"
--workdir /audit
--entrypoint node
"${CONSOLE_IMAGE}"
scripts/ql3-legacy-panel-bundle.cjs
--audit=/opt/qinglong/node_modules/@qinglong/local-api/assets/panel
- name: Generate and reconcile the reviewed CycloneDX SBOM - name: Generate and reconcile the reviewed CycloneDX SBOM
env: env:
IMAGE: qinglong3-local-application:ci-${{ matrix.image_arch }} IMAGE: qinglong3-local-application:ci-${{ matrix.image_arch }}
+1
View File
@@ -11,6 +11,7 @@
# production # production
/static /static
/.ql3-panel-dist
/data /data
# misc # misc
@@ -215,6 +215,8 @@ RUN rm -rf node_modules/.bin \
--retain-js=local-api/assets/console/console.js \ --retain-js=local-api/assets/console/console.js \
&& rm /tmp/ql3-prune-runtime-artifact.cjs && rm /tmp/ql3-prune-runtime-artifact.cjs
COPY .ql3-panel-dist node_modules/@qinglong/local-api/assets/panel
FROM node:24.18.0-alpine3.23@sha256:595398b0081eacda8e1c4c5b97b76cd1020e4d58a8ebcb4843b9bca1e79e7436 AS runtime-platform FROM node:24.18.0-alpine3.23@sha256:595398b0081eacda8e1c4c5b97b76cd1020e4d58a8ebcb4843b9bca1e79e7436 AS runtime-platform
RUN apk add --no-cache --upgrade \ RUN apk add --no-cache --upgrade \
@@ -262,6 +264,9 @@ LABEL org.opencontainers.image.title="QingLong 3.0 Local Console Application" \
io.qinglong.profile="edge-application-api,standalone-application-api" \ io.qinglong.profile="edge-application-api,standalone-application-api" \
io.qinglong.ai="excluded" \ io.qinglong.ai="excluded" \
io.qinglong.local.console="offline-loopback" \ io.qinglong.local.console="offline-loopback" \
io.qinglong.local.panel="legacy-capability-gated@v1" \
io.qinglong.local.panel-max-files="256" \
io.qinglong.local.panel-max-bytes="13631488" \
io.qinglong.local.application-config="2,3,4" \ io.qinglong.local.application-config="2,3,4" \
io.qinglong.local.sqlite-contract-min="51" \ io.qinglong.local.sqlite-contract-min="51" \
io.qinglong.local.sqlite-contract-max="52" \ io.qinglong.local.sqlite-contract-max="52" \
@@ -1,6 +1,6 @@
# ADR-0530:有界 Local 面板能力发现与启动适配 # ADR-0530:有界 Local 面板能力发现与启动适配
- 状态:ProposedD-428 源码候选,等待可下载同源装配与双架构实物门) - 状态:AcceptedD-428 本地同源装配候选已闭合,等待远端 CI 与双架构可下载实物门)
- 日期:2026-09-02 - 日期:2026-09-02
- 关联 RFCQL-RFC-0001 D-428、D-427、D-423、D-424 - 关联 RFCQL-RFC-0001 D-428、D-427、D-423、D-424
@@ -30,20 +30,23 @@ ADR-0529 已交付认证后的只读 `/api/crons` Adapter,但现有 2.x 面板
## 部署与资源边界 ## 部署与资源边界
- 默认 headless 产物不包含 `@qinglong/local-api` 或旧面板,不新增端口、连接、timer、watcher、后台进程或稳态内存。 - 默认 headless 产物不包含 `@qinglong/local-api` 或旧面板,不新增端口、连接、timer、watcher、后台进程或稳态内存。
- 当前约 33 MiB 的旧面板源码构建结果不自动塞入 headless/Console Alpha;同源静态资源装配与体积预算必须作为独立门完成 - 旧面板仍不进入 headless 产物。Console opt-in 镜像只装配经过闭包裁剪与哈希锁定的静态资源:240 files、11,947,127 bytes,上限为 256 files / 13 MiB / 单文件 3 MiB`.gz` 副本与 Monaco Editor 不进入产物
- 装配后的 `/``/login``/crontab``/error` 服务受限旧面板,原生 3.0 管理 Console 固定保留在 `/console`,二者共享同一个 loopback Local API,不启动第二个 Web 服务。
- 静态资源使用 64 KiB streamEdge 的 API admission 为 4、静态资源 admission 为 16Standalone 分别为 32/64。两类请求共享 drain 生命周期但不互相挤占预算,避免旧 Umi 并行加载 chunk 时饿死 API 或收到 503。
- 旧 Umi 前端暂用 Node 20 构建只是 legacy migration toolchain,不改变 QingLong 3.0 Node 24 runtime、双架构镜像或支持等级。该过渡门必须在 CI 中独立命名,不能让 Node 20 定义新 package 的运行时兼容性。 - 旧 Umi 前端暂用 Node 20 构建只是 legacy migration toolchain,不改变 QingLong 3.0 Node 24 runtime、双架构镜像或支持等级。该过渡门必须在 CI 中独立命名,不能让 Node 20 定义新 package 的运行时兼容性。
- Edge 与 Standalone 使用同一代码、不同预算;Cluster 节点不加载 Local SQLite/POSIX authority,也不通过本 Adapter 访问控制面。 - Edge 与 Standalone 使用同一代码、不同预算;Cluster 节点不加载 Local SQLite/POSIX authority,也不通过本 Adapter 访问控制面。
## 验证与剩余门禁 ## 验证与剩余门禁
源码候选已通过 Local API 12-package closure build、89/89 测试、真实 SQLite/credential/Policy/audit HTTP 集成、Node 20 的旧面板 production build、18-package 完整测试与 package/import/dependency audit。Playwright 同源源码旅程也已验证 capability 登录页、内存 credential、只读 Crontab、隐藏写入口与排序/过滤,以及刷新后回到登录页;最终页为 0 console error,仅保留既有国际化 warning。远端 CI 和可下载实物仍待闭合 本地装配候选已通过 12-package Console closure、719 files / 16,162,123 bytes(上限 768 files / 20 MiB)镜像审计,以及面板 240-file 哈希/磁盘闭包复核。最终 arm64 Edge 镜像 `sha256:27472cf1bdd66d9fa4e937622ef69d4e36f74918a759de62818b4e59d265714c` 已完成 fresh setup/replay、Owner provision/challenge/claim/presentation/ack、首个 Task 执行与日志标记、graceful stop、SQLite integrity 和 HTTP 200/401 边界,结果为 `compatible=true`
真实浏览器已验证现有页面使用内存中的 `ql3c_` credential 完成 `/login``/crontab`53 个同源静态请求全部为 200`/api/health``/api/system``/api/v3/capabilities``/api/user``/api/system/config``/api/crons` 均为 200;最终镜像同时验证 `/console` 原生管理台与 `/login` 旧页面共存。旧页面仍引用的外部图标/装饰图片被严格 CSP 阻止,页面功能可用但这些装饰会缺失;本阶段不为消除装饰错误而放宽网络或 CSP。远端 CI 和可下载双架构实物仍待闭合。
仍未完成: 仍未完成:
1. 在一个可下载 Console 产物中同源装配改造后的面板静态资源,并证明 CSP、缓存和 API 路由优先级 1. 推送后通过远端完整 CI,并生成、下载和离线复核 exact amd64/arm64 Console Trial Kit 与 milestone
2. 在装配后的 exact Console + Local API + SQLite 上使用真实 `ql3c_` credential 重跑登录 → `/crontab` → 分页 → 401/刷新清凭据的浏览器 journey 2. 把旧面板引用的外部图标和装饰图片转为受审本地资产,做到严格 CSP 下无外部请求
3. 为旧页面增加 Run/Log 只读 adapter 后再开放日志入口; 3. 为旧页面增加 Run/Log 只读 adapter 后再开放日志入口;
4. 双架构资源与 artifact gate,以及对面板体积的可解释预算; 4. 写操作必须逐项映射到 3.0 revision、Policy、presence/approval、audit 和 mutation fence,不能用通配兼容路由一次性开放。
5. 写操作必须逐项映射到 3.0 revision、Policy、presence/approval、audit 和 mutation fence,不能用通配兼容路由一次性开放。
上述门禁完成前,本 ADR 只说明“现有面板源码能受控接入”,不声明当前已发布 Console artifact 包含该页面,也不声明 2.x 面板可以零修改直连。 远端实物门完成前,本 ADR 声明的是“当前提交可装配并实跑现有面板的受控子集”,不是 Public Release,也不声明完整 2.x 面板可以零修改直连。
+3 -3
View File
@@ -11,7 +11,7 @@
- fresh、隔离的测试数据目录; - fresh、隔离的测试数据目录;
- 离线导入、设备兼容验证和 3.0 Alpha 用户旅程验证。 - 离线导入、设备兼容验证和 3.0 Alpha 用户旅程验证。
`headless` 是默认且最小的低配设备变体,不打开端口。`console` 是显式选择的 Linux-only 变体,携带离线 Web Console,并仅通过宿主 `127.0.0.1:5700` 提供操作面。二者是独立 archive,不应同时下载;远程 Console 只允许经 SSH tunnel 访问,不得暴露到 LAN 或公网。 `headless` 是默认且最小的低配设备变体,不打开端口。`console` 是显式选择的 Linux-only 变体,携带离线 3.0 原生 Web Console 和现有面板的有界只读兼容层,并仅通过宿主 `127.0.0.1:5700` 提供操作面。二者是独立 archive,不应同时下载;远程 Console 只允许经 SSH tunnel 访问,不得暴露到 LAN 或公网。
不要把它直接用于生产数据、2.x 唯一数据目录或生产 Secret。Cluster/Kubernetes 节点应使用 Cluster Integration Candidate;本套件不包含 PostgreSQL HA、Worker 或 Cluster Admin。 不要把它直接用于生产数据、2.x 唯一数据目录或生产 Secret。Cluster/Kubernetes 节点应使用 Cluster Integration Candidate;本套件不包含 PostgreSQL HA、Worker 或 Cluster Admin。
@@ -73,8 +73,8 @@ Owner delivery 保留在新数据目录的 `owner-delivery/`operator command
Headless Application 是无外部 listener、AI-excluded 的最小 Alpha runtime。Console 变体同样 Headless Application 是无外部 listener、AI-excluded 的最小 Alpha runtime。Console 变体同样
AI-excluded,但 quickstart 会在 Linux 上使用 host network,让容器内仍严格绑定 AI-excluded,但 quickstart 会在 Linux 上使用 host network,让容器内仍严格绑定
`127.0.0.1:5700` 的 Local API 可由宿主浏览器访问。成功后打开 `127.0.0.1:5700` 的 Local API 可由宿主浏览器访问。成功后打开
`http://127.0.0.1:5700/`;远程主机必须建立 SSH tunnel。两种变体都只用于 fresh Alpha `http://127.0.0.1:5700/console` 使用 3.0 原生管理台,或打开 `/login` 使用现有面板的只读 Crontab 兼容入口;远程主机必须建立 SSH tunnel。两种变体都只用于 fresh Alpha
不是 2.x Web UI 的生产替代版本。Console 能力和凭据边界见 不是完整 2.x Web UI 的生产替代版本。Console 能力和凭据边界见
[Local Web Console](./ql3-local-web-console.md)。 [Local Web Console](./ql3-local-web-console.md)。
Console quickstart 还会通过 strong local operator 创建一个默认不自动运行的 Console quickstart 还会通过 strong local operator 创建一个默认不自动运行的
+10 -4
View File
@@ -1,6 +1,6 @@
# QingLong 3.0 Local Web Console # QingLong 3.0 Local Web Console
Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来创建和编辑 command Task、管理加密 Secret 绑定、配置 cron Trigger、查看 Task/Run/执行事件,并显式启动或取消一次运行。它由 Console Local Alpha Trial Kit 交付,但不进入默认 headless 变体,也不是 2.x Web UI 的完整替代品。 Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面。Console Trial Kit 现在同源提供两个边界清晰的入口:`/console` 是 3.0 原生管理台,用来创建和编辑 command Task、管理加密 Secret 绑定、配置 cron Trigger、查看 Task/Run/执行事件,并显式启动或取消一次运行`/login``/crontab` 是现有 2.x 面板的 capability-gated 只读兼容入口。它们不进入默认 headless 变体,兼容入口也不是完整 2.x Web UI 的替代品。
## 选择部署档位 ## 选择部署档位
@@ -43,7 +43,7 @@ D-426b2c 又补齐了 Console 镜像的 adopted-target 入口证据:切换演
ql3-local-api --config /srv/qinglong3/private/local-api.json ql3-local-api --config /srv/qinglong3/private/local-api.json
``` ```
在设备本机打开 `http://127.0.0.1:5701/`。服务只接受 `127.0.0.1``::1`,不会监听 LAN 地址。 在设备本机打开 `http://127.0.0.1:5701/console` 使用 3.0 原生管理台;打开 `http://127.0.0.1:5701/login` 使用现有面板的只读 Crontab 兼容入口。当前 Console 产物的 `/` 指向兼容面板。服务只接受 `127.0.0.1``::1`,不会监听 LAN 地址。
从管理电脑访问路由/NAS 时,显式建立受信 SSH tunnel 从管理电脑访问路由/NAS 时,显式建立受信 SSH tunnel
@@ -72,13 +72,19 @@ Credential 只存在当前页面内存,不进入 URL、Cookie 或 Web Storage
## 当前阶段可用边界 ## 当前阶段可用边界
D-424 阶段实物的可操作闭环是内建 argv command Task create/list/read/update/enable/disable/start、Task pinned Secret binding、Secret current metadata/create/rotate、`qinglong/cron@v1` Trigger list/read/create/update/enable/disable,以及 Run list/read/events/steps/log/cancel。Task 编辑器只修改当前展示字段并保留完整快照中的其他 config/labels;其他 Task kind 或 Trigger schema 继续使用受信管理入口。页面暂不负责: D-424 阶段实物的原生 `/console` 可操作闭环是内建 argv command Task create/list/read/update/enable/disable/start、Task pinned Secret binding、Secret current metadata/create/rotate、`qinglong/cron@v1` Trigger list/read/create/update/enable/disable,以及 Run list/read/events/steps/log/cancel。Task 编辑器只修改当前展示字段并保留完整快照中的其他 config/labels;其他 Task kind 或 Trigger schema 继续使用受信管理入口。
现有面板兼容入口只开放 `/login``/crontab``/error` 与只读 Cron 投影。它接受 `ql3c_` credential,不保存在 Web Storage,隐藏创建、批量操作、脚本、订阅、环境变量、日志和所有写 Modal。Env、Script、Subscription、Run/Log 页面和 2.x 写接口都不在兼容承诺内。
页面暂不负责:
- Identity、Policy、Secret 明文读取/删除/历史浏览、Plugin Package 或 AI 配置管理; - Identity、Policy、Secret 明文读取/删除/历史浏览、Plugin Package 或 AI 配置管理;
- Trigger 删除、通用 Trigger provider/schema 编辑或 Cluster Trigger 管理; - Trigger 删除、通用 Trigger provider/schema 编辑或 Cluster Trigger 管理;
- 日志整文件下载、终端、文件管理或 2.x 数据迁移; - 日志整文件下载、终端、文件管理或 2.x 数据迁移;
- LAN/public 暴露、TLS termination、多用户 Web session 或 Cluster 管理。 - LAN/public 暴露、TLS termination、多用户 Web session 或 Cluster 管理。
D-424 的三项静态资产总计 102,182 bytes,不依赖 CDN、网络字体或前端框架,仍低于 192 KiB 总闭包和单文件 96 KiB 门。`edge-application-api|standalone-application-api` 为 4,210,024 / 4,210,168 bytes、482 files、12 packages、111 loaded modules,仍低于 6 MiB/640-file 门;本机 RSS delta 为 20,447,232 / 18,399,232 bytes,低于 28 MiB。默认 headless Edge 为 2,760,847 bytes、332 files、3 packages、59 modulesRSS delta 11,026,432 bytes;它不携带 Console/API 资产、listener 或 Secret mutation surface,只增加复用现有 SQLite connection 的有界 metadata 装配 原生 `/console` 的三项静态资产仍低于 192 KiB 总闭包和单文件 96 KiB 门;兼容面板经裁剪后为 240 files / 11,947,127 bytes,上限 256 files / 13 MiB / 单文件 3 MiB,不携带 `.gz` 副本或 Monaco Editor。最终 Console 镜像闭包为 12 packages、719 files、16,162,123 bytes,低于 768 files / 20 MiB 门;AI 依赖仍被排除。默认 headless Edge 不携带 Console/API/兼容面板资产、listener 或 Secret mutation surface。
静态资源采用 64 KiB 流式发送;Edge 为 API 保留 4 个并发名额、另给静态资源 16 个,Standalone 分别为 32/64。兼容面板残留的外部图标和装饰图片会被严格 CSP 阻止,不影响登录、只读 Crontab 或原生 `/console`;不要为恢复装饰资源放宽 CSP 或联网边界。
停止正常 Local API 进程走与 Application 相同的 drain/shutdown 路径。Console 没有独立数据库、后台任务或需要额外清理的持久状态;一次性 cutover probe 不绑定端口,也不会进入这条常驻生命周期。 停止正常 Local API 进程走与 Application 相同的 drain/shutdown 路径。Console 没有独立数据库、后台任务或需要额外清理的持久状态;一次性 cutover probe 不绑定端口,也不会进入这条常驻生命周期。
@@ -1,9 +1,35 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { lstatSync, readFileSync, realpathSync } from 'node:fs'; import {
existsSync,
lstatSync,
readFileSync,
readdirSync,
realpathSync,
} from 'node:fs';
import path from 'node:path'; import path from 'node:path';
const MAX_ASSET_BYTES = 96 * 1_024; const MAX_LITE_ASSET_BYTES = 96 * 1_024;
const MAX_TOTAL_BYTES = 192 * 1_024; const MAX_LITE_TOTAL_BYTES = 192 * 1_024;
const MAX_PANEL_FILES = 256;
const MAX_PANEL_TOTAL_BYTES = 13 * 1_024 * 1_024;
const MAX_PANEL_FILE_BYTES = 3 * 1_024 * 1_024;
const MAX_MANIFEST_BYTES = 128 * 1_024;
const PANEL_SCHEMA = 'qinglong/local-legacy-panel-assets@v1';
const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable';
const NO_STORE_CACHE = 'no-store';
const LITE_CONTENT_SECURITY_POLICY =
"default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
const PANEL_CONTENT_SECURITY_POLICY =
"default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'; worker-src 'none'; manifest-src 'none'";
const PANEL_CONTENT_TYPES = new Set([
'text/css; charset=utf-8',
'text/html; charset=utf-8',
'text/javascript; charset=utf-8',
'font/ttf',
'font/woff',
'font/woff2',
]);
const PANEL_SUPPORTED_ROUTES = Object.freeze(['/login', '/crontab', '/error']);
const DEFINITIONS = Object.freeze([ const DEFINITIONS = Object.freeze([
Object.freeze({ Object.freeze({
@@ -23,12 +49,26 @@ const DEFINITIONS = Object.freeze([
}), }),
]); ]);
export interface LocalConsoleAsset { interface LocalConsoleAssetBase {
readonly contentType: string; readonly contentType: string;
readonly etag: string; readonly etag: string;
readonly body: Buffer; readonly byteLength: number;
readonly cacheControl: string;
readonly contentSecurityPolicy: string;
} }
export type LocalConsoleAsset =
| (LocalConsoleAssetBase &
Readonly<{
body: Buffer;
filePath?: never;
}>)
| (LocalConsoleAssetBase &
Readonly<{
body?: never;
filePath: string;
}>);
export type LocalConsoleAssets = ReadonlyMap<string, LocalConsoleAsset>; export type LocalConsoleAssets = ReadonlyMap<string, LocalConsoleAsset>;
export class LocalConsoleAssetError extends Error { export class LocalConsoleAssetError extends Error {
@@ -40,7 +80,34 @@ export class LocalConsoleAssetError extends Error {
} }
} }
function loadAsset( function exactKeys(value: unknown, expected: readonly string[]): boolean {
return (
!!value &&
typeof value === 'object' &&
!Array.isArray(value) &&
JSON.stringify(Object.keys(value).sort()) ===
JSON.stringify([...expected].sort())
);
}
function canonicalDirectory(directory: string, label: string): string {
const resolved = path.resolve(directory);
try {
const stat = lstatSync(resolved);
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
realpathSync(resolved) !== resolved
) {
throw new TypeError();
}
} catch (error) {
throw new LocalConsoleAssetError(label, { cause: error });
}
return resolved;
}
function loadLiteAsset(
root: string, root: string,
definition: (typeof DEFINITIONS)[number], definition: (typeof DEFINITIONS)[number],
): Readonly<LocalConsoleAsset> { ): Readonly<LocalConsoleAsset> {
@@ -53,7 +120,7 @@ function loadAsset(
!stat.isFile() || !stat.isFile() ||
stat.isSymbolicLink() || stat.isSymbolicLink() ||
stat.size < 2 || stat.size < 2 ||
stat.size > MAX_ASSET_BYTES || stat.size > MAX_LITE_ASSET_BYTES ||
realpathSync(filePath) !== filePath realpathSync(filePath) !== filePath
) { ) {
throw new TypeError('asset identity is incompatible'); throw new TypeError('asset identity is incompatible');
@@ -70,29 +137,231 @@ function loadAsset(
return Object.freeze({ return Object.freeze({
contentType: definition.contentType, contentType: definition.contentType,
etag: `"${createHash('sha256').update(body).digest('hex')}"`, etag: `"${createHash('sha256').update(body).digest('hex')}"`,
byteLength: body.byteLength,
cacheControl: NO_STORE_CACHE,
contentSecurityPolicy: LITE_CONTENT_SECURITY_POLICY,
body, body,
}); });
} }
export function loadLocalConsoleAssets(): LocalConsoleAssets { function loadLiteAssets(root: string): LocalConsoleAssets {
const root = path.resolve(__dirname, '../../assets/console'); const canonicalRoot = canonicalDirectory(root, 'asset root');
let canonicalRoot: string;
try {
const stat = lstatSync(root);
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new TypeError();
canonicalRoot = realpathSync(root);
} catch (error) {
throw new LocalConsoleAssetError('asset root', { cause: error });
}
const assets = new Map<string, Readonly<LocalConsoleAsset>>(); const assets = new Map<string, Readonly<LocalConsoleAsset>>();
let totalBytes = 0; let totalBytes = 0;
for (const definition of DEFINITIONS) { for (const definition of DEFINITIONS) {
const asset = loadAsset(canonicalRoot, definition); const asset = loadLiteAsset(canonicalRoot, definition);
totalBytes += asset.body.byteLength; totalBytes += asset.byteLength;
if (totalBytes > MAX_TOTAL_BYTES) { if (totalBytes > MAX_LITE_TOTAL_BYTES) {
throw new LocalConsoleAssetError('asset set exceeds its byte budget'); throw new LocalConsoleAssetError('asset set exceeds its byte budget');
} }
assets.set(definition.requestPath, asset); assets.set(definition.requestPath, asset);
} }
return assets; return assets;
} }
function parsePanelManifest(root: string): Record<string, unknown> {
const manifestPath = path.join(root, 'manifest.json');
try {
const stat = lstatSync(manifestPath);
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.size < 256 ||
stat.size > MAX_MANIFEST_BYTES ||
realpathSync(manifestPath) !== manifestPath
) {
throw new TypeError();
}
const value: unknown = JSON.parse(readFileSync(manifestPath, 'utf8'));
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new TypeError();
}
return value as Record<string, unknown>;
} catch (error) {
throw new LocalConsoleAssetError('panel manifest', { cause: error });
}
}
function panelDiskFiles(root: string): readonly string[] {
const result: string[] = [];
const pending = [root];
while (pending.length > 0) {
const directory = pending.pop()!;
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const target = path.join(directory, entry.name);
const stat = lstatSync(target);
if (stat.isSymbolicLink()) {
throw new LocalConsoleAssetError('panel closure contains a symlink');
}
if (stat.isDirectory()) pending.push(target);
else if (stat.isFile()) {
result.push(path.relative(root, target).split(path.sep).join('/'));
} else {
throw new LocalConsoleAssetError(
'panel closure contains a special file',
);
}
}
}
return result.sort();
}
export function loadLocalConsolePanelAssets(
directory: string,
): LocalConsoleAssets {
const root = canonicalDirectory(directory, 'panel asset root');
const manifest = parsePanelManifest(root);
if (
!exactKeys(manifest, [
'schema',
'source',
'supportedRoutes',
'fileCount',
'totalBytes',
'limits',
'files',
]) ||
manifest.schema !== PANEL_SCHEMA ||
manifest.source !== 'qinglong-2.x-capability-gated-panel' ||
JSON.stringify(manifest.supportedRoutes) !==
JSON.stringify(PANEL_SUPPORTED_ROUTES) ||
!exactKeys(manifest.limits, [
'maxFiles',
'maxTotalBytes',
'maxFileBytes',
]) ||
(manifest.limits as Record<string, unknown>).maxFiles !== MAX_PANEL_FILES ||
(manifest.limits as Record<string, unknown>).maxTotalBytes !==
MAX_PANEL_TOTAL_BYTES ||
(manifest.limits as Record<string, unknown>).maxFileBytes !==
MAX_PANEL_FILE_BYTES ||
!Array.isArray(manifest.files) ||
manifest.files.length < 4 ||
manifest.files.length > MAX_PANEL_FILES
) {
throw new LocalConsoleAssetError('panel manifest contract drifted');
}
const assets = new Map<string, LocalConsoleAsset>();
const seenFiles = new Set<string>(['manifest.json']);
let previousRequestPath = '';
let totalBytes = 0;
for (const raw of manifest.files) {
if (
!exactKeys(raw, [
'requestPath',
'file',
'bytes',
'sha256',
'contentType',
'cacheControl',
])
) {
throw new LocalConsoleAssetError('panel asset entry shape drifted');
}
const entry = raw as Record<string, unknown>;
if (
typeof entry.requestPath !== 'string' ||
!entry.requestPath.startsWith('/') ||
entry.requestPath.includes('?') ||
entry.requestPath <= previousRequestPath ||
(entry.requestPath.startsWith('/api/') &&
entry.requestPath !== '/api/env.js') ||
typeof entry.file !== 'string' ||
!/^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/u.test(entry.file) ||
entry.file.split('/').includes('..') ||
seenFiles.has(entry.file) ||
!Number.isSafeInteger(entry.bytes) ||
Number(entry.bytes) < 0 ||
Number(entry.bytes) > MAX_PANEL_FILE_BYTES ||
typeof entry.sha256 !== 'string' ||
!/^[0-9a-f]{64}$/u.test(entry.sha256) ||
typeof entry.contentType !== 'string' ||
!PANEL_CONTENT_TYPES.has(entry.contentType) ||
(entry.cacheControl !== NO_STORE_CACHE &&
entry.cacheControl !== IMMUTABLE_CACHE)
) {
throw new LocalConsoleAssetError('panel asset entry is invalid');
}
previousRequestPath = entry.requestPath;
seenFiles.add(entry.file);
const filePath = path.join(root, ...entry.file.split('/'));
let body: Buffer;
let stat;
try {
stat = lstatSync(filePath);
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.size !== entry.bytes ||
realpathSync(filePath) !== filePath
) {
throw new TypeError();
}
body = readFileSync(filePath);
} catch (error) {
throw new LocalConsoleAssetError(`panel asset ${entry.file}`, {
cause: error,
});
}
if (
body.byteLength !== entry.bytes ||
createHash('sha256').update(body).digest('hex') !== entry.sha256
) {
throw new LocalConsoleAssetError(`panel asset ${entry.file} drifted`);
}
totalBytes += body.byteLength;
assets.set(
entry.requestPath,
Object.freeze({
contentType: entry.contentType,
etag: `"${entry.sha256}"`,
byteLength: body.byteLength,
cacheControl: entry.cacheControl,
contentSecurityPolicy: PANEL_CONTENT_SECURITY_POLICY,
filePath,
}),
);
}
if (
manifest.fileCount !== assets.size ||
manifest.totalBytes !== totalBytes ||
totalBytes > MAX_PANEL_TOTAL_BYTES ||
!assets.has('/') ||
!assets.has('/api/env.js')
) {
throw new LocalConsoleAssetError('panel asset closure drifted');
}
const diskFiles = panelDiskFiles(root);
if (
diskFiles.length !== seenFiles.size ||
diskFiles.some((file) => !seenFiles.has(file))
) {
throw new LocalConsoleAssetError('panel disk closure drifted');
}
const index = assets.get('/')!;
for (const route of PANEL_SUPPORTED_ROUTES) assets.set(route, index);
return assets;
}
export function loadLocalConsoleAssets(): LocalConsoleAssets {
const assetsRoot = path.resolve(__dirname, '../../assets');
const panelRoot = path.join(assetsRoot, 'panel');
const liteAssets = loadLiteAssets(path.join(assetsRoot, 'console'));
if (!existsSync(path.join(panelRoot, 'manifest.json'))) return liteAssets;
const assets = new Map(loadLocalConsolePanelAssets(panelRoot));
for (const requestPath of ['/console.css', '/console.js']) {
if (assets.has(requestPath)) {
throw new LocalConsoleAssetError(
`panel conflicts with native Console asset ${requestPath}`,
);
}
assets.set(requestPath, liteAssets.get(requestPath)!);
}
if (assets.has('/console')) {
throw new LocalConsoleAssetError(
'panel conflicts with native Console route /console',
);
}
assets.set('/console', liteAssets.get('/')!);
return assets;
}
@@ -1,6 +1,8 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { createReadStream } from 'node:fs';
import http, { type IncomingMessage, type ServerResponse } from 'node:http'; import http, { type IncomingMessage, type ServerResponse } from 'node:http';
import type { Socket } from 'node:net'; import type { Socket } from 'node:net';
import { pipeline } from 'node:stream/promises';
import type { LocalApplicationProfile } from '@qinglong/local-application'; import type { LocalApplicationProfile } from '@qinglong/local-application';
@@ -52,9 +54,6 @@ const SECRET_ROUTE_PATTERN =
/^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/secrets$/; /^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/secrets$/;
const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const TASK_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; const TASK_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const LOCAL_CONSOLE_CONTENT_SECURITY_POLICY =
"default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
type LocalApiRouteResolution = type LocalApiRouteResolution =
| LocalApiAdmissionOperation | LocalApiAdmissionOperation
| Readonly<{ | Readonly<{
@@ -895,19 +894,22 @@ function send(
response.end(body); response.end(body);
} }
function sendConsoleAsset( async function sendConsoleAsset(
request: IncomingMessage,
response: ServerResponse, response: ServerResponse,
requestId: string, requestId: string,
asset: Readonly<LocalConsoleAsset>, asset: Readonly<LocalConsoleAsset>,
): void { ): Promise<void> {
if (response.destroyed || response.headersSent) return; if (response.destroyed || response.headersSent) return;
response.statusCode = 200; const ifNoneMatch = rawHeaderValues(request, 'if-none-match');
const notModified =
asset.cacheControl !== 'no-store' &&
ifNoneMatch.length === 1 &&
ifNoneMatch[0] === asset.etag;
response.statusCode = notModified ? 304 : 200;
response.setHeader('content-type', asset.contentType); response.setHeader('content-type', asset.contentType);
response.setHeader('cache-control', 'no-store'); response.setHeader('cache-control', asset.cacheControl);
response.setHeader( response.setHeader('content-security-policy', asset.contentSecurityPolicy);
'content-security-policy',
LOCAL_CONSOLE_CONTENT_SECURITY_POLICY,
);
response.setHeader('cross-origin-opener-policy', 'same-origin'); response.setHeader('cross-origin-opener-policy', 'same-origin');
response.setHeader('cross-origin-resource-policy', 'same-origin'); response.setHeader('cross-origin-resource-policy', 'same-origin');
response.setHeader( response.setHeader(
@@ -919,8 +921,28 @@ function sendConsoleAsset(
response.setHeader('x-frame-options', 'DENY'); response.setHeader('x-frame-options', 'DENY');
response.setHeader('x-request-id', requestId); response.setHeader('x-request-id', requestId);
response.setHeader('etag', asset.etag); response.setHeader('etag', asset.etag);
response.setHeader('content-length', asset.body.byteLength); if (notModified) {
response.end(asset.body); response.end();
return;
}
response.setHeader('content-length', asset.byteLength);
if ('body' in asset) {
await new Promise<void>((resolve, reject) => {
const finish = () => {
response.off('error', reject);
response.off('close', finish);
resolve();
};
response.once('error', reject);
response.once('close', finish);
response.end(asset.body, finish);
});
return;
}
await pipeline(
createReadStream(asset.filePath, { highWaterMark: 64 * 1_024 }),
response,
);
} }
function sendConsoleFavicon(response: ServerResponse, requestId: string): void { function sendConsoleFavicon(response: ServerResponse, requestId: string): void {
@@ -965,10 +987,13 @@ export async function startLocalApiHttpSurface(
validateOptions(options); validateOptions(options);
const consoleAssets = loadLocalConsoleAssets(); const consoleAssets = loadLocalConsoleAssets();
const uuid = options.randomUuid ?? randomUUID; const uuid = options.randomUuid ?? randomUUID;
const maxConcurrentRequests = options.profile === 'edge' ? 4 : 32; const maxConcurrentApiRequests = options.profile === 'edge' ? 4 : 32;
const maxConcurrentAssetRequests = options.profile === 'edge' ? 16 : 64;
const drainTimeoutMs = options.profile === 'edge' ? 5_000 : 10_000; const drainTimeoutMs = options.profile === 'edge' ? 5_000 : 10_000;
let accepting = true; let accepting = true;
const inFlight = new Set<Promise<void>>(); const inFlight = new Set<Promise<void>>();
const apiInFlight = new Set<Promise<void>>();
const assetInFlight = new Set<Promise<void>>();
const sockets = new Set<Socket>(); const sockets = new Set<Socket>();
const server = http.createServer( const server = http.createServer(
@@ -983,21 +1008,39 @@ export async function startLocalApiHttpSurface(
send(response, requestId, errorResponse(503, 'server_draining')); send(response, requestId, errorResponse(503, 'server_draining'));
return; return;
} }
if (inFlight.size >= maxConcurrentRequests) {
send(response, requestId, errorResponse(503, 'server_overloaded'));
return;
}
const consoleAsset = const consoleAsset =
request.method === 'GET' && typeof request.url === 'string' request.method === 'GET' && typeof request.url === 'string'
? consoleAssets.get(request.url) ? consoleAssets.get(request.url)
: undefined; : undefined;
if (consoleAsset) { if (consoleAsset) {
if (assetInFlight.size >= maxConcurrentAssetRequests) {
send(response, requestId, errorResponse(503, 'server_overloaded'));
return;
}
if (hasRequestBody(request)) { if (hasRequestBody(request)) {
send(response, requestId, errorResponse(400, 'invalid_request_body')); send(response, requestId, errorResponse(400, 'invalid_request_body'));
request.resume(); request.resume();
return; return;
} }
sendConsoleAsset(response, requestId, consoleAsset); let operation: Promise<void>;
operation = sendConsoleAsset(request, response, requestId, consoleAsset)
.catch(() => {
if (!response.headersSent) {
send(
response,
requestId,
errorResponse(503, 'response_unavailable'),
);
} else if (!response.destroyed) {
response.destroy();
}
})
.finally(() => {
assetInFlight.delete(operation);
inFlight.delete(operation);
});
assetInFlight.add(operation);
inFlight.add(operation);
return; return;
} }
if (request.method === 'GET' && request.url === '/favicon.ico') { if (request.method === 'GET' && request.url === '/favicon.ico') {
@@ -1025,6 +1068,10 @@ export async function startLocalApiHttpSurface(
); );
return; return;
} }
if (apiInFlight.size >= maxConcurrentApiRequests) {
send(response, requestId, errorResponse(503, 'server_overloaded'));
return;
}
const resolvedRoute = route(request, options.profile); const resolvedRoute = route(request, options.profile);
if (!resolvedRoute) { if (!resolvedRoute) {
send(response, requestId, errorResponse(404, 'route_not_found')); send(response, requestId, errorResponse(404, 'route_not_found'));
@@ -1126,15 +1173,17 @@ export async function startLocalApiHttpSurface(
send(response, requestId, errorResponse(503, 'request_unavailable')), send(response, requestId, errorResponse(503, 'request_unavailable')),
) )
.finally(() => { .finally(() => {
apiInFlight.delete(operation);
inFlight.delete(operation); inFlight.delete(operation);
}); });
apiInFlight.add(operation);
inFlight.add(operation); inFlight.add(operation);
}, },
); );
server.headersTimeout = 5_000; server.headersTimeout = 5_000;
server.keepAliveTimeout = 5_000; server.keepAliveTimeout = 5_000;
server.maxRequestsPerSocket = 100; server.maxRequestsPerSocket = 100;
server.maxConnections = maxConcurrentRequests * 2; server.maxConnections = maxConcurrentApiRequests + maxConcurrentAssetRequests;
server.on('connection', (socket) => { server.on('connection', (socket) => {
sockets.add(socket); sockets.add(socket);
socket.once('close', () => sockets.delete(socket)); socket.once('close', () => sockets.delete(socket));
@@ -1,14 +1,56 @@
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const fs = require('node:fs');
const http = require('node:http'); const http = require('node:http');
const net = require('node:net'); const net = require('node:net');
const os = require('node:os');
const path = require('node:path');
const { test } = require('node:test'); const { test } = require('node:test');
const { const {
loadLocalConsoleAssets, loadLocalConsoleAssets,
loadLocalConsolePanelAssets,
} = require('../dist/console/localConsoleAssets.js'); } = require('../dist/console/localConsoleAssets.js');
const { const {
startLocalApiHttpSurface, startLocalApiHttpSurface,
} = require('../dist/transport/httpSurface.js'); } = require('../dist/transport/httpSurface.js');
const {
bundleLegacyPanel,
} = require('../../../scripts/ql3-legacy-panel-bundle.cjs');
function panelFixture() {
const root = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-local-api-panel-')),
);
const source = path.join(root, 'source');
const output = path.join(root, 'output');
fs.mkdirSync(source);
fs.writeFileSync(
path.join(source, 'index.html'),
'<!DOCTYPE html>\n' +
'<html><head>\n' +
'<link rel="shortcut icon" href="https://qn.whyour.cn/favicon.svg">\n' +
'<link rel="stylesheet" href="./umi.1234abcd.css">\n' +
'<script src="./api/env.js"></script>\n' +
'</head><body><div id="root"></div>\n' +
'<script src="./umi.1234abcd.js"></script></body></html>\n',
);
fs.writeFileSync(
path.join(source, 'umi.1234abcd.css'),
'body { color: #123; }\n',
);
fs.writeFileSync(
path.join(source, 'umi.1234abcd.js'),
'globalThis.__panel = true;\n',
);
bundleLegacyPanel(source, output);
return {
root,
output,
close() {
fs.rmSync(root, { recursive: true, force: true });
},
};
}
function reservePort() { function reservePort() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -117,6 +159,63 @@ test('loads one bounded offline Console asset closure', () => {
assert.ok(totalBytes <= 192 * 1024); assert.ok(totalBytes <= 192 * 1024);
}); });
test('keeps the native Console route beside a manifested legacy panel', () => {
const source = fs.readFileSync(
path.join(__dirname, '../dist/console/localConsoleAssets.js'),
'utf8',
);
assert.match(source, /assets\.set\('\/console', liteAssets\.get\('\/'\)\)/u);
assert.match(source, /panel conflicts with native Console asset/u);
});
test('loads the manifested legacy panel as a streamed bounded closure', (t) => {
const current = panelFixture();
t.after(() => current.close());
const assets = loadLocalConsolePanelAssets(current.output);
assert.deepEqual(
[...assets.keys()],
[
'/',
'/api/env.js',
'/umi.1234abcd.css',
'/umi.1234abcd.js',
'/login',
'/crontab',
'/error',
],
);
const index = assets.get('/');
assert.equal(index, assets.get('/login'));
assert.equal(index, assets.get('/crontab'));
assert.equal(index, assets.get('/error'));
assert.equal(index.body, undefined);
assert.equal(path.isAbsolute(index.filePath), true);
assert.equal(index.cacheControl, 'no-store');
assert.match(
index.contentSecurityPolicy,
/style-src 'self' 'unsafe-inline'/u,
);
assert.match(index.contentSecurityPolicy, /connect-src 'self'/u);
const script = assets.get('/umi.1234abcd.js');
assert.equal(script.body, undefined);
assert.equal(script.cacheControl, 'public, max-age=31536000, immutable');
assert.equal(script.byteLength, 27);
assert.match(script.etag, /^"[0-9a-f]{64}"$/u);
assert.equal(assets.get('/api/env.js').cacheControl, 'no-store');
});
test('rejects a manifested panel whose immutable asset changed', (t) => {
const current = panelFixture();
t.after(() => current.close());
const scriptPath = path.join(current.output, 'umi.1234abcd.js');
fs.chmodSync(scriptPath, 0o600);
fs.appendFileSync(scriptPath, 'drift');
assert.throws(
() => loadLocalConsolePanelAssets(current.output),
/panel asset /u,
);
});
test('serves the Console without authentication and preserves API admission', async (t) => { test('serves the Console without authentication and preserves API admission', async (t) => {
const calls = []; const calls = [];
const port = await reservePort(); const port = await reservePort();
@@ -177,6 +276,31 @@ test('serves the Console without authentication and preserves API admission', as
assert.deepEqual(calls, ['task.list']); assert.deepEqual(calls, ['task.list']);
}); });
test('serves an Edge browser asset burst without consuming API admission slots', async (t) => {
const port = await reservePort();
const active = await startLocalApiHttpSurface({
profile: 'edge',
host: '127.0.0.1',
port,
admission: {
async prepare() {
throw new Error('static assets must not reach admission');
},
},
});
t.after(() => active.stopAndDrain());
const responses = await Promise.all(
Array.from({ length: 12 }, (_, index) =>
request(port, index % 2 === 0 ? '/console.js' : '/console.css'),
),
);
assert.deepEqual(
responses.map(({ statusCode }) => statusCode),
Array.from({ length: 12 }, () => 200),
);
});
test('rejects request bodies and query aliases on Console assets', async (t) => { test('rejects request bodies and query aliases on Console assets', async (t) => {
const port = await reservePort(); const port = await reservePort();
const active = await startLocalApiHttpSurface({ const active = await startLocalApiHttpSurface({
+408
View File
@@ -0,0 +1,408 @@
#!/usr/bin/env node
'use strict';
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const SCHEMA = 'qinglong/local-legacy-panel-assets@v1';
const MAX_FILES = 256;
const MAX_TOTAL_BYTES = 13 * 1024 * 1024;
const MAX_FILE_BYTES = 3 * 1024 * 1024;
const SUPPORTED_ROUTES = Object.freeze(['/login', '/crontab', '/error']);
const CONTENT_TYPES = Object.freeze({
'.css': 'text/css; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.ttf': 'font/ttf',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
});
const HASHED_ASSET =
/\.[0-9a-f]{8}(?:\.[A-Za-z0-9_-]+)*\.(?:css|js|ttf|woff2?)$/u;
const EXTERNAL_FAVICON =
/<link rel="shortcut icon" href="https:\/\/qn\.whyour\.cn\/favicon\.svg">\r?\n?/u;
const ENVIRONMENT_SOURCE =
"window.__ENV__ = Object.freeze({ QlBaseUrl: '/', DeployEnv: '', QL_DIR: '' });\n";
function fail(message) {
throw new Error(`QingLong legacy panel bundle failed: ${message}`);
}
function sha256(body) {
return crypto.createHash('sha256').update(body).digest('hex');
}
function canonicalDirectory(directory, label) {
const resolved = path.resolve(directory);
if (resolved === path.parse(resolved).root) fail(`${label} is too broad`);
let stat;
try {
stat = fs.lstatSync(resolved);
} catch {
fail(`${label} is unavailable`);
}
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
fs.realpathSync(resolved) !== resolved
) {
fail(`${label} must be a canonical directory`);
}
return resolved;
}
function outputDirectory(directory, sourceRoot) {
const resolved = path.resolve(directory);
if (
resolved === path.parse(resolved).root ||
resolved === sourceRoot ||
resolved.startsWith(`${sourceRoot}${path.sep}`) ||
sourceRoot.startsWith(`${resolved}${path.sep}`) ||
fs.existsSync(resolved)
) {
fail('output must be an absent directory outside the source closure');
}
const parent = canonicalDirectory(path.dirname(resolved), 'output parent');
if (path.dirname(resolved) !== parent) fail('output parent drifted');
return resolved;
}
function sourceFiles(root) {
const files = [];
const pending = [root];
while (pending.length > 0) {
const directory = pending.pop();
const entries = fs
.readdirSync(directory, { withFileTypes: true })
.sort((left, right) => left.name.localeCompare(right.name));
for (const entry of entries) {
const filePath = path.join(directory, entry.name);
const stat = fs.lstatSync(filePath);
if (stat.isSymbolicLink()) fail('source closure contains a symlink');
if (stat.isDirectory()) {
pending.push(filePath);
continue;
}
if (!stat.isFile()) fail('source closure contains a special file');
const relative = path.relative(root, filePath).split(path.sep).join('/');
if (relative.endsWith('.gz') || relative.startsWith('monaco-editor/')) {
continue;
}
const extension = path.extname(relative);
if (!Object.hasOwn(CONTENT_TYPES, extension)) {
fail(`unsupported source asset ${relative}`);
}
if (relative !== 'index.html' && !HASHED_ASSET.test(relative)) {
fail(`mutable source asset ${relative}`);
}
files.push(
Object.freeze({ filePath, relative, extension, bytes: stat.size }),
);
}
}
files.sort((left, right) => left.relative.localeCompare(right.relative));
if (
files.length < 3 ||
files.length > MAX_FILES - 1 ||
files[0]?.relative === undefined ||
!files.some(({ relative }) => relative === 'index.html')
) {
fail('source asset count or entrypoint is invalid');
}
return files;
}
function normalizedIndex(source) {
const index = source.toString('utf8');
if (
!index.includes('<div id="root"></div>') ||
!index.includes('<script src="./api/env.js"></script>') ||
!/<script src="\.\/umi\.[0-9a-f]{8}\.js"><\/script>/u.test(index) ||
!/<link rel="stylesheet" href="\.\/umi\.[0-9a-f]{8}\.css">/u.test(index) ||
!EXTERNAL_FAVICON.test(index)
) {
fail('legacy panel entrypoint contract drifted');
}
const normalized = index.replace(EXTERNAL_FAVICON, '');
if (/https?:\/\//u.test(normalized)) {
fail('legacy panel entrypoint retains an external origin');
}
return Buffer.from(normalized, 'utf8');
}
function assetRecord(relative, body) {
const extension = path.extname(relative);
const requestPath = relative === 'index.html' ? '/' : `/${relative}`;
return Object.freeze({
requestPath,
file: relative,
bytes: body.byteLength,
sha256: sha256(body),
contentType: CONTENT_TYPES[extension],
cacheControl:
relative === 'index.html' || relative === 'api/env.js'
? 'no-store'
: 'public, max-age=31536000, immutable',
});
}
function writeAsset(outputRoot, relative, body) {
const target = path.join(outputRoot, ...relative.split('/'));
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o755 });
fs.writeFileSync(target, body, { flag: 'wx', mode: 0o444 });
}
function exactKeys(value, expected) {
return (
value &&
typeof value === 'object' &&
!Array.isArray(value) &&
JSON.stringify(Object.keys(value).sort()) ===
JSON.stringify([...expected].sort())
);
}
function readManifest(root) {
const manifestPath = path.join(root, 'manifest.json');
const stat = fs.lstatSync(manifestPath);
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.size < 256 ||
stat.size > 128 * 1024
) {
fail('manifest identity is invalid');
}
return JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
}
function auditLegacyPanelBundle(directory) {
const root = canonicalDirectory(directory, 'bundle root');
const manifest = readManifest(root);
if (
!exactKeys(manifest, [
'schema',
'source',
'supportedRoutes',
'fileCount',
'totalBytes',
'limits',
'files',
]) ||
manifest.schema !== SCHEMA ||
manifest.source !== 'qinglong-2.x-capability-gated-panel' ||
JSON.stringify(manifest.supportedRoutes) !==
JSON.stringify(SUPPORTED_ROUTES) ||
!exactKeys(manifest.limits, [
'maxFiles',
'maxTotalBytes',
'maxFileBytes',
]) ||
manifest.limits.maxFiles !== MAX_FILES ||
manifest.limits.maxTotalBytes !== MAX_TOTAL_BYTES ||
manifest.limits.maxFileBytes !== MAX_FILE_BYTES ||
!Array.isArray(manifest.files) ||
manifest.files.length < 4 ||
manifest.files.length > MAX_FILES
) {
fail('manifest contract drifted');
}
const observed = [];
let totalBytes = 0;
let previousPath = '';
const seenFiles = new Set(['manifest.json']);
for (const entry of manifest.files) {
if (
!exactKeys(entry, [
'requestPath',
'file',
'bytes',
'sha256',
'contentType',
'cacheControl',
]) ||
typeof entry.requestPath !== 'string' ||
!entry.requestPath.startsWith('/') ||
entry.requestPath.includes('?') ||
entry.requestPath <= previousPath ||
typeof entry.file !== 'string' ||
!/^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/u.test(entry.file) ||
entry.file.split('/').includes('..') ||
!Number.isSafeInteger(entry.bytes) ||
entry.bytes < 0 ||
entry.bytes > MAX_FILE_BYTES ||
!/^[0-9a-f]{64}$/u.test(entry.sha256) ||
!Object.values(CONTENT_TYPES).includes(entry.contentType) ||
(entry.cacheControl !== 'no-store' &&
entry.cacheControl !== 'public, max-age=31536000, immutable') ||
seenFiles.has(entry.file)
) {
fail(
`manifest asset entry is invalid after ${previousPath}: ${String(
entry?.requestPath,
).slice(0, 256)}`,
);
}
previousPath = entry.requestPath;
seenFiles.add(entry.file);
const filePath = path.join(root, ...entry.file.split('/'));
const stat = fs.lstatSync(filePath);
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
fs.realpathSync(filePath) !== filePath ||
stat.size !== entry.bytes
) {
fail(`asset identity drifted: ${entry.file}`);
}
const body = fs.readFileSync(filePath);
if (sha256(body) !== entry.sha256) {
fail(`asset digest drifted: ${entry.file}`);
}
totalBytes += body.byteLength;
observed.push(entry.requestPath);
}
const diskFiles = sourceFilesForAudit(root);
if (
diskFiles.length !== seenFiles.size ||
diskFiles.some((relative) => !seenFiles.has(relative)) ||
manifest.fileCount !== manifest.files.length ||
manifest.totalBytes !== totalBytes ||
totalBytes > MAX_TOTAL_BYTES ||
!observed.includes('/') ||
!observed.includes('/api/env.js')
) {
fail('bundle closure or budget drifted');
}
return Object.freeze({
schema: manifest.schema,
files: manifest.fileCount,
bytes: manifest.totalBytes,
maxFiles: MAX_FILES,
maxBytes: MAX_TOTAL_BYTES,
supportedRoutes: Object.freeze([...SUPPORTED_ROUTES]),
compatible: true,
});
}
function sourceFilesForAudit(root) {
const result = [];
const pending = [root];
while (pending.length > 0) {
const directory = pending.pop();
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const target = path.join(directory, entry.name);
const stat = fs.lstatSync(target);
if (stat.isSymbolicLink()) fail('bundle contains a symlink');
if (stat.isDirectory()) pending.push(target);
else if (stat.isFile()) {
result.push(path.relative(root, target).split(path.sep).join('/'));
} else fail('bundle contains a special file');
}
}
return result.sort();
}
function bundleLegacyPanel(sourceDirectory, outputDirectoryPath) {
const sourceRoot = canonicalDirectory(sourceDirectory, 'source root');
const outputRoot = outputDirectory(outputDirectoryPath, sourceRoot);
const files = sourceFiles(sourceRoot);
fs.mkdirSync(outputRoot, { mode: 0o755 });
try {
const records = [];
for (const file of files) {
const source = fs.readFileSync(file.filePath);
if (
source.byteLength !== file.bytes ||
source.byteLength > MAX_FILE_BYTES
) {
fail(`source asset size drifted: ${file.relative}`);
}
const body =
file.relative === 'index.html' ? normalizedIndex(source) : source;
writeAsset(outputRoot, file.relative, body);
records.push(assetRecord(file.relative, body));
}
writeAsset(outputRoot, 'api/env.js', Buffer.from(ENVIRONMENT_SOURCE));
records.push(assetRecord('api/env.js', Buffer.from(ENVIRONMENT_SOURCE)));
records.sort((left, right) =>
left.requestPath < right.requestPath
? -1
: left.requestPath > right.requestPath
? 1
: 0,
);
const totalBytes = records.reduce((total, entry) => total + entry.bytes, 0);
if (records.length > MAX_FILES || totalBytes > MAX_TOTAL_BYTES) {
fail('output asset budget exceeded');
}
const manifest = {
schema: SCHEMA,
source: 'qinglong-2.x-capability-gated-panel',
supportedRoutes: [...SUPPORTED_ROUTES],
fileCount: records.length,
totalBytes,
limits: {
maxFiles: MAX_FILES,
maxTotalBytes: MAX_TOTAL_BYTES,
maxFileBytes: MAX_FILE_BYTES,
},
files: records,
};
fs.writeFileSync(
path.join(outputRoot, 'manifest.json'),
`${JSON.stringify(manifest, null, 2)}\n`,
{ flag: 'wx', mode: 0o444 },
);
return auditLegacyPanelBundle(outputRoot);
} catch (error) {
fs.rmSync(outputRoot, { recursive: true, force: true });
throw error;
}
}
function argumentsFrom(argv) {
if (argv.length === 1 && argv[0].startsWith('--audit=')) {
const directory = argv[0].slice('--audit='.length);
if (!path.isAbsolute(directory)) fail('audit root must be absolute');
return Object.freeze({ mode: 'audit', directory });
}
if (
argv.length !== 2 ||
!argv[0].startsWith('--source=') ||
!argv[1].startsWith('--output=')
) {
fail('usage: --source=/absolute/static/dist --output=/absolute/bundle');
}
const source = argv[0].slice('--source='.length);
const output = argv[1].slice('--output='.length);
if (!path.isAbsolute(source) || !path.isAbsolute(output)) {
fail('source and output must be absolute');
}
return Object.freeze({ mode: 'bundle', source, output });
}
if (require.main === module) {
try {
const options = argumentsFrom(process.argv.slice(2));
process.stdout.write(
`${JSON.stringify(
options.mode === 'audit'
? auditLegacyPanelBundle(options.directory)
: bundleLegacyPanel(options.source, options.output),
)}\n`,
);
} catch (error) {
process.stderr.write(
`${error instanceof Error ? error.message : String(error)}\n`,
);
process.exitCode = 1;
}
}
module.exports = Object.freeze({
auditLegacyPanelBundle,
bundleLegacyPanel,
});
@@ -5,8 +5,8 @@
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const MAX_FILES = 640; const MAX_FILES = 768;
const MAX_BYTES = 6 * 1024 * 1024; const MAX_BYTES = 20 * 1024 * 1024;
const EXPECTED_PACKAGES = Object.freeze([ const EXPECTED_PACKAGES = Object.freeze([
'@qinglong/local-admin', '@qinglong/local-admin',
'@qinglong/local-api', '@qinglong/local-api',
@@ -109,10 +109,7 @@ function main() {
const manifest = JSON.parse( const manifest = JSON.parse(
fs.readFileSync(path.join(root, packageName, 'package.json'), 'utf8'), fs.readFileSync(path.join(root, packageName, 'package.json'), 'utf8'),
); );
if ( if (manifest.name !== packageName || typeof manifest.version !== 'string') {
manifest.name !== packageName ||
typeof manifest.version !== 'string'
) {
fail(`package identity drifted: ${packageName}`); fail(`package identity drifted: ${packageName}`);
} }
} }
+40
View File
@@ -298,6 +298,9 @@ function auditDockerfile(contents, findings) {
'COPY --from=workspace /workspace/packages/ql3-local-process/assets \\\n' + 'COPY --from=workspace /workspace/packages/ql3-local-process/assets \\\n' +
' node_modules/@qinglong/local-process/assets', ' node_modules/@qinglong/local-process/assets',
) || ) ||
!consoleAssembledStage.includes(
'COPY .ql3-panel-dist node_modules/@qinglong/local-api/assets/panel',
) ||
!consoleAssembledStage.includes( !consoleAssembledStage.includes(
'RUN chmod 0555 node_modules/@qinglong/local-process/assets/ql3-launcher.sh', 'RUN chmod 0555 node_modules/@qinglong/local-process/assets/ql3-launcher.sh',
) )
@@ -365,6 +368,13 @@ function auditDockerfile(contents, findings) {
!consoleRuntimeStage.includes( !consoleRuntimeStage.includes(
'io.qinglong.local.console="offline-loopback"', 'io.qinglong.local.console="offline-loopback"',
) || ) ||
!consoleRuntimeStage.includes(
'io.qinglong.local.panel="legacy-capability-gated@v1"',
) ||
!consoleRuntimeStage.includes('io.qinglong.local.panel-max-files="256"') ||
!consoleRuntimeStage.includes(
'io.qinglong.local.panel-max-bytes="13631488"',
) ||
!consoleRuntimeStage.includes('io.qinglong.ai="excluded"') !consoleRuntimeStage.includes('io.qinglong.ai="excluded"')
) { ) {
addFinding(findings, 'CONSOLE_RUNTIME_IDENTITY_OR_LABEL_DRIFT'); addFinding(findings, 'CONSOLE_RUNTIME_IDENTITY_OR_LABEL_DRIFT');
@@ -372,6 +382,28 @@ function auditDockerfile(contents, findings) {
} }
function auditWorkflow(contents, findings) { function auditWorkflow(contents, findings) {
const panelMatch =
/\n legacy-panel-compatibility:\n([\s\S]*?)(?=\n [a-z0-9-]+:\n)/.exec(
contents,
);
if (!panelMatch) {
addFinding(findings, 'LEGACY_PANEL_ARTIFACT_CI_JOB_MISSING');
} else {
const panelJob = panelMatch[1];
for (const value of [
"node-version: '20.20.2'",
'pnpm build:front',
'scripts/ql3-legacy-panel-bundle.cjs',
'--source="${GITHUB_WORKSPACE}/static/dist"',
'--output="${RUNNER_TEMP}/ql3-legacy-panel"',
'name: ql3-legacy-panel-${{ github.run_id }}-${{ github.run_attempt }}',
'compression-level: 0',
]) {
if (!panelJob.includes(value)) {
addFinding(findings, 'LEGACY_PANEL_ARTIFACT_CI_CONTRACT_DRIFT', value);
}
}
}
const match = /\n local-image:\n([\s\S]*?)(?=\n [a-z0-9-]+:\n)/.exec( const match = /\n local-image:\n([\s\S]*?)(?=\n [a-z0-9-]+:\n)/.exec(
contents, contents,
); );
@@ -383,11 +415,19 @@ function auditWorkflow(contents, findings) {
const required = [ const required = [
'runner: ubuntu-24.04\n node_arch: x64\n image_arch: amd64', 'runner: ubuntu-24.04\n node_arch: x64\n image_arch: amd64',
'runner: ubuntu-24.04-arm\n node_arch: arm64\n image_arch: arm64', 'runner: ubuntu-24.04-arm\n node_arch: arm64\n image_arch: arm64',
'needs: legacy-panel-compatibility',
'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c',
'name: ql3-legacy-panel-${{ github.run_id }}-${{ github.run_attempt }}',
'path: .ql3-panel-dist',
'scripts/ql3-legacy-panel-bundle.cjs --audit="${GITHUB_WORKSPACE}/.ql3-panel-dist"',
'pnpm audit:local-image:ql3', 'pnpm audit:local-image:ql3',
'docker build', 'docker build',
'--file deploy/containers/ql3-local-application/Dockerfile', '--file deploy/containers/ql3-local-application/Dockerfile',
'--target runtime', '--target runtime',
'--target runtime-console', '--target runtime-console',
'io.qinglong.local.panel',
'io.qinglong.local.panel-max-files',
'io.qinglong.local.panel-max-bytes',
'qinglong3-local-console:ci-${{ matrix.image_arch }}', 'qinglong3-local-console:ci-${{ matrix.image_arch }}',
'EXPECTED: ${{ matrix.image_arch }} 65532:65532 2,3,4 51 52 52 1', 'EXPECTED: ${{ matrix.image_arch }} 65532:65532 2,3,4 51 52 52 1',
'actual="$(docker image inspect --format \'{{.Architecture}} {{.Config.User}} {{index .Config.Labels "io.qinglong.local.application-config"}} {{index .Config.Labels "io.qinglong.local.sqlite-contract-min"}} {{index .Config.Labels "io.qinglong.local.sqlite-contract-max"}} {{index .Config.Labels "io.qinglong.local.sqlite-write-contract"}} {{index .Config.Labels "io.qinglong.local.compose-selection"}}\' "${IMAGE}")"', 'actual="$(docker image inspect --format \'{{.Architecture}} {{.Config.User}} {{index .Config.Labels "io.qinglong.local.application-config"}} {{index .Config.Labels "io.qinglong.local.sqlite-contract-min"}} {{index .Config.Labels "io.qinglong.local.sqlite-contract-max"}} {{index .Config.Labels "io.qinglong.local.sqlite-write-contract"}} {{index .Config.Labels "io.qinglong.local.compose-selection"}}\' "${IMAGE}")"',
+140
View File
@@ -0,0 +1,140 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const {
auditLegacyPanelBundle,
bundleLegacyPanel,
} = require('../../scripts/ql3-legacy-panel-bundle.cjs');
function fixture() {
const root = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-panel-bundle-')),
);
const source = path.join(root, 'source');
const output = path.join(root, 'output');
fs.mkdirSync(path.join(source, 'monaco-editor'), { recursive: true });
fs.writeFileSync(
path.join(source, 'index.html'),
'<!DOCTYPE html>\n' +
'<html><head>\n' +
'<link rel="shortcut icon" href="https://qn.whyour.cn/favicon.svg">\n' +
'<link rel="stylesheet" href="./umi.1234abcd.css">\n' +
'<script src="./api/env.js"></script>\n' +
'</head><body><div id="root"></div>\n' +
'<script src="./umi.1234abcd.js"></script></body></html>\n',
);
fs.writeFileSync(
path.join(source, 'umi.1234abcd.css'),
'body { color: #123; }\n',
);
fs.writeFileSync(
path.join(source, 'umi.1234abcd.js'),
'globalThis.__panel = true;\n',
);
fs.writeFileSync(path.join(source, 'umi.1234abcd.js.gz'), 'not shipped');
fs.writeFileSync(
path.join(source, 'monaco-editor', 'editor.1234abcd.js'),
'not shipped',
);
return {
root,
source,
output,
close() {
fs.rmSync(root, { recursive: true, force: true });
},
};
}
test('materializes one bounded offline legacy panel closure', (t) => {
const current = fixture();
t.after(() => current.close());
const report = bundleLegacyPanel(current.source, current.output);
assert.deepEqual(report, {
schema: 'qinglong/local-legacy-panel-assets@v1',
files: 4,
bytes: report.bytes,
maxFiles: 256,
maxBytes: 13 * 1024 * 1024,
supportedRoutes: ['/login', '/crontab', '/error'],
compatible: true,
});
assert.ok(report.bytes > 200);
assert.equal(
fs.existsSync(path.join(current.output, 'umi.1234abcd.js.gz')),
false,
);
assert.equal(
fs.existsSync(path.join(current.output, 'monaco-editor')),
false,
);
const index = fs.readFileSync(
path.join(current.output, 'index.html'),
'utf8',
);
assert.equal(index.includes('https://'), false);
const environment = fs.readFileSync(
path.join(current.output, 'api/env.js'),
'utf8',
);
assert.equal(environment.includes("QlBaseUrl: '/'"), true);
const manifest = JSON.parse(
fs.readFileSync(path.join(current.output, 'manifest.json'), 'utf8'),
);
assert.equal(manifest.files[0].requestPath, '/');
assert.equal(
manifest.files.find(({ requestPath }) => requestPath === '/api/env.js')
.cacheControl,
'no-store',
);
assert.equal(
manifest.files.find(({ requestPath }) =>
/\.[0-9a-f]{8}\.js$/u.test(requestPath),
).cacheControl,
'public, max-age=31536000, immutable',
);
assert.deepEqual(auditLegacyPanelBundle(current.output), report);
});
test('rejects bundle replacement and post-build asset drift', (t) => {
const current = fixture();
t.after(() => current.close());
bundleLegacyPanel(current.source, current.output);
assert.throws(
() => bundleLegacyPanel(current.source, current.output),
/output must be an absent directory/u,
);
const assetPath = path.join(current.output, 'umi.1234abcd.js');
fs.chmodSync(assetPath, 0o600);
fs.appendFileSync(assetPath, 'drift');
assert.throws(
() => auditLegacyPanelBundle(current.output),
/asset identity drifted|asset digest drifted/u,
);
});
test('rejects mutable assets and external entrypoint drift', (t) => {
const mutable = fixture();
t.after(() => mutable.close());
fs.writeFileSync(path.join(mutable.source, 'runtime.js'), 'mutable');
assert.throws(
() => bundleLegacyPanel(mutable.source, mutable.output),
/mutable source asset/u,
);
const external = fixture();
t.after(() => external.close());
fs.appendFileSync(
path.join(external.source, 'index.html'),
'<script src="https://example.invalid/panel.js"></script>\n',
);
assert.throws(
() => bundleLegacyPanel(external.source, external.output),
/retains an external origin/u,
);
});
@@ -60,6 +60,8 @@ test('accepts the exact bounded Local Console package closure', (t) => {
assert.equal(report.packageCount, 12); assert.equal(report.packageCount, 12);
assert.equal(report.ai, 'excluded'); assert.equal(report.ai, 'excluded');
assert.equal(report.listener, 'loopback-only'); assert.equal(report.listener, 'loopback-only');
assert.equal(report.maxFiles, 768);
assert.equal(report.maxBytes, 20 * 1024 * 1024);
assert.equal(report.compatible, true); assert.equal(report.compatible, true);
}); });