mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 08:05:22 +08:00
feat(ql3): ship bounded legacy panel console
This commit is contained in:
@@ -132,6 +132,21 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
- name: Build the capability-gated legacy panel source
|
||||
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:
|
||||
name: systemd/OpenRC dual-authority bridge
|
||||
@@ -373,6 +388,7 @@ jobs:
|
||||
|
||||
local-image:
|
||||
name: Local application image (${{ matrix.image_arch }})
|
||||
needs: legacy-panel-compatibility
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -386,6 +402,11 @@ jobs:
|
||||
image_arch: arm64
|
||||
steps:
|
||||
- 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
|
||||
with:
|
||||
version: '8.3.1'
|
||||
@@ -396,6 +417,8 @@ jobs:
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
- name: Verify native runner architecture
|
||||
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
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
- name: Audit immutable local image inputs
|
||||
@@ -515,10 +538,10 @@ jobs:
|
||||
- name: Verify the opt-in Local Console identity
|
||||
env:
|
||||
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: |
|
||||
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
|
||||
echo "unexpected Local Console image contract: ${actual}" >&2
|
||||
exit 1
|
||||
@@ -565,6 +588,20 @@ jobs:
|
||||
"${CONSOLE_IMAGE}"
|
||||
scripts/ql3-local-console-image-inventory.cjs
|
||||
--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
|
||||
env:
|
||||
IMAGE: qinglong3-local-application:ci-${{ matrix.image_arch }}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
# production
|
||||
/static
|
||||
/.ql3-panel-dist
|
||||
/data
|
||||
|
||||
# misc
|
||||
|
||||
@@ -215,6 +215,8 @@ RUN rm -rf node_modules/.bin \
|
||||
--retain-js=local-api/assets/console/console.js \
|
||||
&& 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
|
||||
|
||||
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.ai="excluded" \
|
||||
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.sqlite-contract-min="51" \
|
||||
io.qinglong.local.sqlite-contract-max="52" \
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR-0530:有界 Local 面板能力发现与启动适配
|
||||
|
||||
- 状态:Proposed(D-428 源码候选,等待可下载同源装配与双架构实物门)
|
||||
- 状态:Accepted(D-428 本地同源装配候选已闭合,等待远端 CI 与双架构可下载实物门)
|
||||
- 日期:2026-09-02
|
||||
- 关联 RFC:QL-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、后台进程或稳态内存。
|
||||
- 当前约 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 stream;Edge 的 API admission 为 4、静态资源 admission 为 16,Standalone 分别为 32/64。两类请求共享 drain 生命周期但不互相挤占预算,避免旧 Umi 并行加载 chunk 时饿死 API 或收到 503。
|
||||
- 旧 Umi 前端暂用 Node 20 构建只是 legacy migration toolchain,不改变 QingLong 3.0 Node 24 runtime、双架构镜像或支持等级。该过渡门必须在 CI 中独立命名,不能让 Node 20 定义新 package 的运行时兼容性。
|
||||
- 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 路由优先级;
|
||||
2. 在装配后的 exact Console + Local API + SQLite 上使用真实 `ql3c_` credential 重跑登录 → `/crontab` → 分页 → 401/刷新清凭据的浏览器 journey;
|
||||
1. 推送后通过远端完整 CI,并生成、下载和离线复核 exact amd64/arm64 Console Trial Kit 与 milestone;
|
||||
2. 把旧面板引用的外部图标和装饰图片转为受审本地资产,做到严格 CSP 下无外部请求;
|
||||
3. 为旧页面增加 Run/Log 只读 adapter 后再开放日志入口;
|
||||
4. 双架构资源与 artifact gate,以及对面板体积的可解释预算;
|
||||
5. 写操作必须逐项映射到 3.0 revision、Policy、presence/approval、audit 和 mutation fence,不能用通配兼容路由一次性开放。
|
||||
4. 写操作必须逐项映射到 3.0 revision、Policy、presence/approval、audit 和 mutation fence,不能用通配兼容路由一次性开放。
|
||||
|
||||
在上述门禁完成前,本 ADR 只说明“现有面板源码能受控接入”,不声明当前已发布 Console artifact 包含该页面,也不声明 2.x 面板可以零修改直连。
|
||||
在远端实物门完成前,本 ADR 声明的是“当前提交可装配并实跑现有面板的受控子集”,不是 Public Release,也不声明完整 2.x 面板可以零修改直连。
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- fresh、隔离的测试数据目录;
|
||||
- 离线导入、设备兼容验证和 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。
|
||||
|
||||
@@ -73,8 +73,8 @@ Owner delivery 保留在新数据目录的 `owner-delivery/`,operator command
|
||||
Headless Application 是无外部 listener、AI-excluded 的最小 Alpha runtime。Console 变体同样
|
||||
AI-excluded,但 quickstart 会在 Linux 上使用 host network,让容器内仍严格绑定
|
||||
`127.0.0.1:5700` 的 Local API 可由宿主浏览器访问。成功后打开
|
||||
`http://127.0.0.1:5700/`;远程主机必须建立 SSH tunnel。两种变体都只用于 fresh Alpha,
|
||||
不是 2.x Web UI 的生产替代版本。Console 能力和凭据边界见
|
||||
`http://127.0.0.1:5700/console` 使用 3.0 原生管理台,或打开 `/login` 使用现有面板的只读 Crontab 兼容入口;远程主机必须建立 SSH tunnel。两种变体都只用于 fresh Alpha,
|
||||
不是完整 2.x Web UI 的生产替代版本。Console 能力和凭据边界见
|
||||
[Local Web Console](./ql3-local-web-console.md)。
|
||||
|
||||
Console quickstart 还会通过 strong local operator 创建一个默认不自动运行的
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
在设备本机打开 `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:
|
||||
|
||||
@@ -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 配置管理;
|
||||
- Trigger 删除、通用 Trigger provider/schema 编辑或 Cluster Trigger 管理;
|
||||
- 日志整文件下载、终端、文件管理或 2.x 数据迁移;
|
||||
- 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 modules,RSS 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 不绑定端口,也不会进入这条常驻生命周期。
|
||||
|
||||
@@ -1,9 +1,35 @@
|
||||
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';
|
||||
|
||||
const MAX_ASSET_BYTES = 96 * 1_024;
|
||||
const MAX_TOTAL_BYTES = 192 * 1_024;
|
||||
const MAX_LITE_ASSET_BYTES = 96 * 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([
|
||||
Object.freeze({
|
||||
@@ -23,12 +49,26 @@ const DEFINITIONS = Object.freeze([
|
||||
}),
|
||||
]);
|
||||
|
||||
export interface LocalConsoleAsset {
|
||||
interface LocalConsoleAssetBase {
|
||||
readonly contentType: 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 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,
|
||||
definition: (typeof DEFINITIONS)[number],
|
||||
): Readonly<LocalConsoleAsset> {
|
||||
@@ -53,7 +120,7 @@ function loadAsset(
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.size < 2 ||
|
||||
stat.size > MAX_ASSET_BYTES ||
|
||||
stat.size > MAX_LITE_ASSET_BYTES ||
|
||||
realpathSync(filePath) !== filePath
|
||||
) {
|
||||
throw new TypeError('asset identity is incompatible');
|
||||
@@ -70,29 +137,231 @@ function loadAsset(
|
||||
return Object.freeze({
|
||||
contentType: definition.contentType,
|
||||
etag: `"${createHash('sha256').update(body).digest('hex')}"`,
|
||||
byteLength: body.byteLength,
|
||||
cacheControl: NO_STORE_CACHE,
|
||||
contentSecurityPolicy: LITE_CONTENT_SECURITY_POLICY,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
export function loadLocalConsoleAssets(): LocalConsoleAssets {
|
||||
const root = path.resolve(__dirname, '../../assets/console');
|
||||
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 });
|
||||
}
|
||||
function loadLiteAssets(root: string): LocalConsoleAssets {
|
||||
const canonicalRoot = canonicalDirectory(root, 'asset root');
|
||||
const assets = new Map<string, Readonly<LocalConsoleAsset>>();
|
||||
let totalBytes = 0;
|
||||
for (const definition of DEFINITIONS) {
|
||||
const asset = loadAsset(canonicalRoot, definition);
|
||||
totalBytes += asset.body.byteLength;
|
||||
if (totalBytes > MAX_TOTAL_BYTES) {
|
||||
const asset = loadLiteAsset(canonicalRoot, definition);
|
||||
totalBytes += asset.byteLength;
|
||||
if (totalBytes > MAX_LITE_TOTAL_BYTES) {
|
||||
throw new LocalConsoleAssetError('asset set exceeds its byte budget');
|
||||
}
|
||||
assets.set(definition.requestPath, asset);
|
||||
}
|
||||
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 { createReadStream } from 'node:fs';
|
||||
import http, { type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import type { Socket } from 'node:net';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
|
||||
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$/;
|
||||
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 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 =
|
||||
| LocalApiAdmissionOperation
|
||||
| Readonly<{
|
||||
@@ -895,19 +894,22 @@ function send(
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function sendConsoleAsset(
|
||||
async function sendConsoleAsset(
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
requestId: string,
|
||||
asset: Readonly<LocalConsoleAsset>,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
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('cache-control', 'no-store');
|
||||
response.setHeader(
|
||||
'content-security-policy',
|
||||
LOCAL_CONSOLE_CONTENT_SECURITY_POLICY,
|
||||
);
|
||||
response.setHeader('cache-control', asset.cacheControl);
|
||||
response.setHeader('content-security-policy', asset.contentSecurityPolicy);
|
||||
response.setHeader('cross-origin-opener-policy', 'same-origin');
|
||||
response.setHeader('cross-origin-resource-policy', 'same-origin');
|
||||
response.setHeader(
|
||||
@@ -919,8 +921,28 @@ function sendConsoleAsset(
|
||||
response.setHeader('x-frame-options', 'DENY');
|
||||
response.setHeader('x-request-id', requestId);
|
||||
response.setHeader('etag', asset.etag);
|
||||
response.setHeader('content-length', asset.body.byteLength);
|
||||
response.end(asset.body);
|
||||
if (notModified) {
|
||||
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 {
|
||||
@@ -965,10 +987,13 @@ export async function startLocalApiHttpSurface(
|
||||
validateOptions(options);
|
||||
const consoleAssets = loadLocalConsoleAssets();
|
||||
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;
|
||||
let accepting = true;
|
||||
const inFlight = new Set<Promise<void>>();
|
||||
const apiInFlight = new Set<Promise<void>>();
|
||||
const assetInFlight = new Set<Promise<void>>();
|
||||
const sockets = new Set<Socket>();
|
||||
|
||||
const server = http.createServer(
|
||||
@@ -983,21 +1008,39 @@ export async function startLocalApiHttpSurface(
|
||||
send(response, requestId, errorResponse(503, 'server_draining'));
|
||||
return;
|
||||
}
|
||||
if (inFlight.size >= maxConcurrentRequests) {
|
||||
send(response, requestId, errorResponse(503, 'server_overloaded'));
|
||||
return;
|
||||
}
|
||||
const consoleAsset =
|
||||
request.method === 'GET' && typeof request.url === 'string'
|
||||
? consoleAssets.get(request.url)
|
||||
: undefined;
|
||||
if (consoleAsset) {
|
||||
if (assetInFlight.size >= maxConcurrentAssetRequests) {
|
||||
send(response, requestId, errorResponse(503, 'server_overloaded'));
|
||||
return;
|
||||
}
|
||||
if (hasRequestBody(request)) {
|
||||
send(response, requestId, errorResponse(400, 'invalid_request_body'));
|
||||
request.resume();
|
||||
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;
|
||||
}
|
||||
if (request.method === 'GET' && request.url === '/favicon.ico') {
|
||||
@@ -1025,6 +1068,10 @@ export async function startLocalApiHttpSurface(
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (apiInFlight.size >= maxConcurrentApiRequests) {
|
||||
send(response, requestId, errorResponse(503, 'server_overloaded'));
|
||||
return;
|
||||
}
|
||||
const resolvedRoute = route(request, options.profile);
|
||||
if (!resolvedRoute) {
|
||||
send(response, requestId, errorResponse(404, 'route_not_found'));
|
||||
@@ -1126,15 +1173,17 @@ export async function startLocalApiHttpSurface(
|
||||
send(response, requestId, errorResponse(503, 'request_unavailable')),
|
||||
)
|
||||
.finally(() => {
|
||||
apiInFlight.delete(operation);
|
||||
inFlight.delete(operation);
|
||||
});
|
||||
apiInFlight.add(operation);
|
||||
inFlight.add(operation);
|
||||
},
|
||||
);
|
||||
server.headersTimeout = 5_000;
|
||||
server.keepAliveTimeout = 5_000;
|
||||
server.maxRequestsPerSocket = 100;
|
||||
server.maxConnections = maxConcurrentRequests * 2;
|
||||
server.maxConnections = maxConcurrentApiRequests + maxConcurrentAssetRequests;
|
||||
server.on('connection', (socket) => {
|
||||
sockets.add(socket);
|
||||
socket.once('close', () => sockets.delete(socket));
|
||||
|
||||
@@ -1,14 +1,56 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const http = require('node:http');
|
||||
const net = require('node:net');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
loadLocalConsoleAssets,
|
||||
loadLocalConsolePanelAssets,
|
||||
} = require('../dist/console/localConsoleAssets.js');
|
||||
const {
|
||||
startLocalApiHttpSurface,
|
||||
} = 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() {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -117,6 +159,63 @@ test('loads one bounded offline Console asset closure', () => {
|
||||
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) => {
|
||||
const calls = [];
|
||||
const port = await reservePort();
|
||||
@@ -177,6 +276,31 @@ test('serves the Console without authentication and preserves API admission', as
|
||||
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) => {
|
||||
const port = await reservePort();
|
||||
const active = await startLocalApiHttpSurface({
|
||||
|
||||
@@ -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 path = require('node:path');
|
||||
|
||||
const MAX_FILES = 640;
|
||||
const MAX_BYTES = 6 * 1024 * 1024;
|
||||
const MAX_FILES = 768;
|
||||
const MAX_BYTES = 20 * 1024 * 1024;
|
||||
const EXPECTED_PACKAGES = Object.freeze([
|
||||
'@qinglong/local-admin',
|
||||
'@qinglong/local-api',
|
||||
@@ -109,10 +109,7 @@ function main() {
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(root, packageName, 'package.json'), 'utf8'),
|
||||
);
|
||||
if (
|
||||
manifest.name !== packageName ||
|
||||
typeof manifest.version !== 'string'
|
||||
) {
|
||||
if (manifest.name !== packageName || typeof manifest.version !== 'string') {
|
||||
fail(`package identity drifted: ${packageName}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,6 +298,9 @@ function auditDockerfile(contents, findings) {
|
||||
'COPY --from=workspace /workspace/packages/ql3-local-process/assets \\\n' +
|
||||
' node_modules/@qinglong/local-process/assets',
|
||||
) ||
|
||||
!consoleAssembledStage.includes(
|
||||
'COPY .ql3-panel-dist node_modules/@qinglong/local-api/assets/panel',
|
||||
) ||
|
||||
!consoleAssembledStage.includes(
|
||||
'RUN chmod 0555 node_modules/@qinglong/local-process/assets/ql3-launcher.sh',
|
||||
)
|
||||
@@ -365,6 +368,13 @@ function auditDockerfile(contents, findings) {
|
||||
!consoleRuntimeStage.includes(
|
||||
'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"')
|
||||
) {
|
||||
addFinding(findings, 'CONSOLE_RUNTIME_IDENTITY_OR_LABEL_DRIFT');
|
||||
@@ -372,6 +382,28 @@ function auditDockerfile(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(
|
||||
contents,
|
||||
);
|
||||
@@ -383,11 +415,19 @@ function auditWorkflow(contents, findings) {
|
||||
const required = [
|
||||
'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',
|
||||
'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',
|
||||
'docker build',
|
||||
'--file deploy/containers/ql3-local-application/Dockerfile',
|
||||
'--target runtime',
|
||||
'--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 }}',
|
||||
'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}")"',
|
||||
|
||||
@@ -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.ai, 'excluded');
|
||||
assert.equal(report.listener, 'loopback-only');
|
||||
assert.equal(report.maxFiles, 768);
|
||||
assert.equal(report.maxBytes, 20 * 1024 * 1024);
|
||||
assert.equal(report.compatible, true);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user