From 882ce07d7e7e858be16cd89332a11b052af5d803 Mon Sep 17 00:00:00 2001 From: whyour Date: Wed, 2 Sep 2026 11:30:42 +0800 Subject: [PATCH] feat(ql3): add bounded panel bootstrap --- .github/workflows/ql3-ci.yml | 20 ++ docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 4 +- ...nded-read-only-local-panel-cron-adapter.md | 10 +- ...ounded-local-panel-capability-bootstrap.md | 49 ++++ docs/adr/README.md | 3 +- .../src/admission/localApiAdmission.ts | 18 +- .../localApiProductSurface.ts | 3 + .../panelBootstrapRoute.ts | 169 +++++++++++++ .../panel-compatibility/panelCronListRoute.ts | 12 +- .../src/transport/httpSurface.ts | 89 ++++++- .../ql3-local-api/test/admission.test.cjs | 32 +++ .../ql3-local-api/test/httpSurface.test.cjs | 107 ++++++++- .../test/panelBootstrapRoute.test.cjs | 150 ++++++++++++ .../test/panelCronListRoute.test.cjs | 12 + .../test/sqliteHttp.integration.test.cjs | 31 +++ src/layouts/index.tsx | 72 +++++- src/pages/crontab/index.tsx | 227 +++++++++++------- src/pages/login/index.tsx | 103 ++++++-- src/utils/http.tsx | 5 +- src/utils/qinglong3.ts | 113 +++++++++ 20 files changed, 1095 insertions(+), 134 deletions(-) create mode 100644 docs/adr/ADR-0530-bounded-local-panel-capability-bootstrap.md create mode 100644 packages/ql3-local-api/src/panel-compatibility/panelBootstrapRoute.ts create mode 100644 packages/ql3-local-api/test/panelBootstrapRoute.test.cjs create mode 100644 src/utils/qinglong3.ts diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index edbe58aa..761d1cb7 100644 --- a/.github/workflows/ql3-ci.yml +++ b/.github/workflows/ql3-ci.yml @@ -114,6 +114,25 @@ jobs: node:${{ matrix.node }}-bookworm-slim node --test test/back/completionReceiptOrphanAuditor.test.cjs + legacy-panel-compatibility: + name: Legacy panel compatibility build (Node 20 migration toolchain) + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 + with: + version: '8.3.1' + - uses: actions/setup-node@v6 + with: + node-version: '20.20.2' + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + - name: Install workspace dependencies without lifecycle scripts + run: pnpm install --frozen-lockfile --ignore-scripts + - name: Build the capability-gated legacy panel source + run: pnpm build:front + service-manager-bridge: name: systemd/OpenRC dual-authority bridge runs-on: ubuntu-24.04 @@ -2082,6 +2101,7 @@ jobs: if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && (inputs.alpha_artifact_scope == 'local' || inputs.alpha_artifact_scope == 'all') needs: - backend + - legacy-panel-compatibility - service-manager-bridge - linux-resource-envelopes - linux-resource-release-evidence diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 2502f7bf..0ad5873d 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -1,6 +1,8 @@ # QingLong 3.0 Architecture RFC -- D-427/ADR-0529(源码候选,等待双架构阶段实物):开始把现有 2.x 面板从“不能零改直连”推进为受控页面复用,而不是把旧 Express Service 或数据库写 authority 搬回 3.0。第一切片只在既有 `@qinglong/local-api/panel-compatibility` 子域开放认证后的 `GET /api/crons`:正式 Bearer credential、`task.read` Policy、durable audit、credential reconfirm 后,按 Edge 64/Standalone 256 行上限读取 `qinglong/cron@v1` Trigger 前缀,并以 Trigger 固定的 `taskId/revision/contentDigest` 读取 pinned Task revision。每个 Trigger 投影成一条旧 Cron 包络,字符串 `triggerId` 保持稳定身份,schedule/timezone/misfire 来自规范化 Trigger;command 只返回 `ql3::@` 描述符,不暴露 argv、环境、Secret、label、mutation 或 digest。缺失 pinned Task、identity 漂移、未知 Trigger schema、异常页和超预算均整体失败关闭。首切片只接受旧页面初始空 search/filter 与有界 page/size;搜索、排序、View query 和全部写操作明确拒绝。它不新增 package、dependency、migration、连接、listener、timer 或 watcher;headless 与 Cluster 路径不变,也不提供 2.x login/JWT、静态面板、WebSocket、Cron View、Subscription、Script、Env 或 Run/Log 兼容。本地真实 SQLite/credential/Policy/HTTP 集成、Local API 83/83、18-package clean build/test 和 Edge/Standalone Application API 资源门已通过;仍须完成双架构 artifact 门,并继续实现 capability shell 与 Run/Log read,才能把改造后的现有页面称为阶段性可用。 +- D-428/ADR-0530(源码候选,等待可下载同源装配与双架构阶段实物):在 D-427 的只读 Cron Adapter 上增加显式 Local 面板能力发现和启动边界,而不是伪造完整 2.x 后端。公开、无敏感数据的 exact `GET /api/health`、`GET /api/system`、`GET /api/v3/capabilities` 只接受无 body 与可选单个数字 `t`;认证后的 `GET /api/user`、`GET /api/system/config` 仍经过 Bearer、`task.read` Policy、durable audit 与 credential reconfirm,并只接受 User principal。capability v1 区分 Edge/Standalone 的 64/256 Cron 行与 16/32 KiB 日志块预算,并明确关闭 legacy login/mutation、Subscription、Script、Env 和 WebSocket。现有面板源码只在严格发现 `qinglong3` capability 后进入 3.0 模式:登录页改收 `ql3c_` API Credential,凭据仅驻留模块内存且刷新即丢失;认证成功只进入 `/crontab`;菜单、请求和 Modal 降级为名称/命令描述符/状态/schedule 的有界只读列表,不请求 View、Subscription、非空搜索/排序,也不建立 WebSocket。2.x 模式保持原行为。该切片不新增 package、production dependency、migration、数据库连接、listener、timer、watcher 或后台进程;默认 headless、Cluster authority 与 Node 24 runtime 均不变,旧 Umi 暂用 Node 20 只作为 legacy migration build toolchain。Local API 12-package closure 与 89/89、18-package 完整测试、package/import/dependency audit、旧面板 production build 均通过;Playwright 同源源码旅程验证了登录、只读列表和刷新清凭据,最终 0 console error。仍须把改造静态资源装入可下载 Console、在 exact Console + Local API + SQLite 上重跑真实 credential journey、通过远端 CI 和双架构 artifact,才能声明阶段页面产物可直接部署。 + +- D-427/ADR-0529(双架构阶段实物已交付):开始把现有 2.x 面板从“不能零改直连”推进为受控页面复用,而不是把旧 Express Service 或数据库写 authority 搬回 3.0。第一切片只在既有 `@qinglong/local-api/panel-compatibility` 子域开放认证后的 `GET /api/crons`:正式 Bearer credential、`task.read` Policy、durable audit、credential reconfirm 后,按 Edge 64/Standalone 256 行上限读取 `qinglong/cron@v1` Trigger 前缀,并以 Trigger 固定的 `taskId/revision/contentDigest` 读取 pinned Task revision。每个 Trigger 投影成一条旧 Cron 包络,字符串 `triggerId` 保持稳定身份,schedule/timezone/misfire 来自规范化 Trigger;command 只返回 `ql3::@` 描述符,不暴露 argv、环境、Secret、label、mutation 或 digest。缺失 pinned Task、identity 漂移、未知 Trigger schema、异常页和超预算均整体失败关闭。首切片只接受旧页面初始空 search/filter 与有界 page/size;搜索、排序、View query 和全部写操作明确拒绝。它不新增 package、dependency、migration、连接、listener、timer 或 watcher;headless 与 Cluster 路径不变,也不提供 2.x login/JWT、静态面板、WebSocket、Cron View、Subscription、Script、Env 或 Run/Log 兼容。本地真实 SQLite/credential/Policy/HTTP 集成、Local API 83/83、18-package clean build/test 和 Edge/Standalone Application API 资源门已通过。提交 `83966a128705f6ef0ae54367c8f656e3d578e9c6` 的显式 Local headless [run 33576693917](https://github.com/whyour/qinglong/actions/runs/33576693917) 与 Local Console [run 33578754251](https://github.com/whyour/qinglong/actions/runs/33578754251) 均成功;headless amd64/arm64/milestone artifact `9827434236`/`9827259756`/`9827553099`,Console `9828792297`/`9828714089`/`9828941802`,均未过期并保留至 2026-10-02。D-427 因此闭合为后端阶段实物;它仍不包含改造后的 2.x 静态面板,页面交付由 D-428 独立闭合。 - D-426c3/ADR-0528(源码候选,等待 exact 双架构阶段实物):在 D-426c2 的三阶段回滚链上保留 `apply-rollback`,新增显式 `apply-plan` 与 `complete`。完成型 CI 使用完整 2.x schema、空 Apps/Auths、无未知插件表的独立 fixture;外部 completion review 对 blocked 事实直接拒绝,对 Legacy/Target Run History 分别授权 `retain_both/retain_target`,对 Secret/Config 只授权 `manual_external`。`apply-plan` 消费互相独立的 Automation/review decision,依次完成 Automation apply/verify、双侧终态 Run History preservation/verify、Secret/Config plan/verify 和 decision prepare,停在 `secret_config_decision_required`;`complete` 再消费独立 Secret/Config decision,完成 decision/apply/verify,并以 completion v3 同时绑定三类 adapter,要求 `reconciliation_completed`、`adapterCount=3`。Run History 必须位于 Automation apply 与会推进 head 的 Secret/Config plan 之间。completion 后 target/Legacy 仍 stopped,两个 restart 都是 `not_authorized`。针对 readiness 要求 Apps/Auths 必须存在而旧诊断又无条件阻塞 identity 域的矛盾,仅把“Legacy 已知 identity 表全部为空”收窄为 `informational/catalog_evidence`;任一身份行、未知表、目标 identity 或异常仍 fail-closed。Trial Kit/verification/auditor 升为 `@v11/@v9/@v8`、manifest schemaVersion 12,Local milestone 升为 `@v7`/schemaVersion 7,并新增 required `legacyUpgradeReconciliationCompletion=passed`;CI 仍独立保留 Automation apply→rollback 实证,completion 不覆盖 recovery 证据。本切片不增加 package、production dependency、daemon/listener/timer/watcher/连接或稳态资源,默认低配 headless 与 Cluster authority 边界不变。 diff --git a/docs/adr/ADR-0529-bounded-read-only-local-panel-cron-adapter.md b/docs/adr/ADR-0529-bounded-read-only-local-panel-cron-adapter.md index f111eafd..f538102e 100644 --- a/docs/adr/ADR-0529-bounded-read-only-local-panel-cron-adapter.md +++ b/docs/adr/ADR-0529-bounded-read-only-local-panel-cron-adapter.md @@ -1,6 +1,6 @@ # ADR-0529:有界只读 Local 旧面板 Cron Adapter -- 状态:Proposed(源码候选,尚未进入双架构阶段实物) +- 状态:Accepted(D-427 双架构阶段实物已交付) - 日期:2026-09-02 - 关联 RFC:QL-RFC-0001 D-427、D-423、D-424、D-426c3 @@ -25,7 +25,7 @@ QingLong 2.x 面板以 `/api/crons`、数值型 Cron 行和 `{code,data}` 包络 - 接受旧页面初始读取所需的 `page`、`size`、空 `searchValue`、空 `filters={}` 和 Axios cache-buster `t`。 - 非空搜索、排序、View query 或其他字段暂时返回 `400 invalid_panel_cron_list_query`,不能静默忽略并给出错误结果。 -- `size` 最大 64;`page * size` 在 Edge 最大 64、Standalone 最大 256。Adapter 用同一上限向 Trigger source 做一次有界 keyset 前缀读取,再截取所需页。 +- `size` 最大 64;页起点 `(page - 1) * size` 必须小于 Edge 64、Standalone 256 行预算。Adapter 以 `min(page * size, maximumRows)` 向 Trigger source 做一次有界 keyset 前缀读取,再截取所需页,因此最后一个不足整页的窗口仍可到达,但不能越过 Profile 上限。 - `total` 是当前已观察前缀加一个 `truncated` 继续标记;它足以让旧分页逐页推进,但不执行无界 COUNT 或全表扫描。 ### 领域映射 @@ -48,7 +48,7 @@ QingLong 2.x 面板以 `/api/crons`、数值型 Cron 行和 `{code,data}` 包络 ## 验证与后续门禁 -源码候选必须通过: +本切片通过: 1. Adapter 单测:分页、禁用合成、pinned identity、未知 schema、预算和 storage failure; 2. HTTP 契约:编码的 `{}` 查询、正式 operation 解析、拒绝非空搜索且不进入 Admission; @@ -56,4 +56,6 @@ QingLong 2.x 面板以 `/api/crons`、数值型 Cron 行和 `{code,data}` 包络 4. 真实 SQLite 集成:正式 credential、Policy、Task/Trigger revision 和 durable audit,且响应不出现真实 argv; 5. Local API 全包、18-package build/test、dependency/import、Console/Headless image 与双架构 artifact 门。 -后续按 `health/system/user capability → Run/Log read → 显式写操作` 推进。只有改造版面板取消 Local Storage credential、按 capability 隐藏未实现页面,并完成真实浏览器 journey 后,才能声明“现有面板页面可复用”;完整 2.x 零改兼容不作为 3.0 目标。 +提交 `83966a128705f6ef0ae54367c8f656e3d578e9c6` 的显式 Local headless [run 33576693917](https://github.com/whyour/qinglong/actions/runs/33576693917) 与 Local Console [run 33578754251](https://github.com/whyour/qinglong/actions/runs/33578754251) 均成功。headless amd64/arm64/milestone artifact 为 `9827434236`/`9827259756`/`9827553099`,Console 为 `9828792297`/`9828714089`/`9828941802`;均绑定同一 source revision,保留至 2026-10-02。headless 继续不携带 Local API/旧面板;Console artifact 携带本 Adapter,但仍只证明后端兼容切片,不包含改造后的 2.x 静态面板。 + +后续按 `health/system/user capability → Run/Log read → 显式写操作` 推进。ADR-0530 已完成 capability/bootstrap 与旧面板源码的内存凭据、菜单降级、只读 Crontab 适配;仍须完成同源静态资源装配和真实浏览器 journey,才能把该页面作为可下载阶段产物交付。完整 2.x 零改兼容不作为 3.0 目标。 diff --git a/docs/adr/ADR-0530-bounded-local-panel-capability-bootstrap.md b/docs/adr/ADR-0530-bounded-local-panel-capability-bootstrap.md new file mode 100644 index 00000000..5667e1cb --- /dev/null +++ b/docs/adr/ADR-0530-bounded-local-panel-capability-bootstrap.md @@ -0,0 +1,49 @@ +# ADR-0530:有界 Local 面板能力发现与启动适配 + +- 状态:Proposed(D-428 源码候选,等待可下载同源装配与双架构实物门) +- 日期:2026-09-02 +- 关联 RFC:QL-RFC-0001 D-428、D-427、D-423、D-424 + +## 背景 + +ADR-0529 已交付认证后的只读 `/api/crons` Adapter,但现有 2.x 面板启动时还会读取 `/api/system`、`/api/system/config`、`/api/user`,并假设用户名/密码登录、Local Storage JWT、完整菜单和 WebSocket 可用。仅有 Cron API 时,页面仍不能安全接入;若伪造完整 2.x 能力,则会把尚未实现的写操作、Env、Script、Subscription 和日志入口暴露给用户。 + +因此第二切片必须先让页面明确发现“这是 QingLong 3.0、当前 Profile 有哪些能力”,再只启用已闭合的页面和认证方式。兼容层继续是翻译边界,不恢复旧 Express Service、JWT authority 或数据库直写。 + +## 决策 + +### 能力与启动契约 + +- Local API 新增公开只读 `GET /api/health`、`GET /api/system`、`GET /api/v3/capabilities`。它们只接受无 body 的 exact path 与可选单个数字 `t`,仅返回版本、Local Profile、认证种类、能力开关和有界预算,不返回 principal、credential、Project 数据或存储路径。 +- `GET /api/user` 与 `GET /api/system/config` 继续走正式 Bearer authentication、`task.read` Policy、durable audit 和 credential reconfirm;operation 固定为 `panel.user.get` 与 `panel.system.config.get`,且面板身份只接受 User principal,service credential 失败关闭。 +- capability schema 固定为 v1。Edge 最多暴露 64 条 Cron、16 KiB 日志块;Standalone 最多 256 条 Cron、32 KiB 日志块;单页最多 64 条。Cluster 不复用该 Local capability shell,后续由独立 Panel Gateway 发布其能力。 +- 明确声明 `legacyLogin=false`、`legacyMutations=false`、`subscriptions=false`、`scripts=false`、`environmentVariables=false`、`webSocket=false`。现有 v3 Task/Trigger/Run/Log 读 API 可以存在,但本切片的旧页面只启用 Crontab 只读投影。 + +### 现有面板源码降级 + +- 面板在登录页同源读取 `/api/v3/capabilities`;只有严格匹配 v1 契约才进入 QingLong 3.0 模式,否则保持 2.x 行为。 +- QingLong 3.0 模式只接受 `ql3c_` API Credential。凭据只保存在当前 JavaScript 模块内存,通过 Authorization Bearer 发送;不写 Local Storage、Cookie、IndexedDB 或 URL,刷新和关闭页面后必须重新输入。 +- 成功读取 `/api/user` 后进入 `/crontab`。菜单只保留登录、错误页和定时任务;不建立 WebSocket,不请求 Cron View、Subscription 或非空搜索/排序。 +- Crontab 只展示名称、命令描述符、状态和计划;隐藏创建、选择、批量操作、详情、脚本跳转、日志和全部写 Modal。分页被 capability budget 限制,最后一个不足整页的 Edge/Standalone 窗口仍可读取。 +- 2.x 模式的数据流、Local Storage token、登录接口、菜单和页面行为保持不变。 + +## 部署与资源边界 + +- 默认 headless 产物不包含 `@qinglong/local-api` 或旧面板,不新增端口、连接、timer、watcher、后台进程或稳态内存。 +- 当前约 33 MiB 的旧面板源码构建结果不自动塞入 headless/Console Alpha;同源静态资源装配与体积预算必须作为独立门完成。 +- 旧 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 和可下载实物仍待闭合。 + +仍未完成: + +1. 在一个可下载 Console 产物中同源装配改造后的面板静态资源,并证明 CSP、缓存和 API 路由优先级; +2. 在装配后的 exact Console + Local API + SQLite 上使用真实 `ql3c_` credential 重跑登录 → `/crontab` → 分页 → 401/刷新清凭据的浏览器 journey; +3. 为旧页面增加 Run/Log 只读 adapter 后再开放日志入口; +4. 双架构资源与 artifact gate,以及对面板体积的可解释预算; +5. 写操作必须逐项映射到 3.0 revision、Policy、presence/approval、audit 和 mutation fence,不能用通配兼容路由一次性开放。 + +在上述门禁完成前,本 ADR 只说明“现有面板源码能受控接入”,不声明当前已发布 Console artifact 包含该页面,也不声明 2.x 面板可以零修改直连。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 47bf8fc6..0be970bf 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -532,7 +532,8 @@ | [ADR-0526](./ADR-0526-exact-post-write-reconciliation-capture.md) | Exact 写后 Reconciliation Capture | Accepted(同源 exact Console 双架构阶段实物已交付) | | [ADR-0527](./ADR-0527-reviewed-automation-reconciliation-application-and-rollback.md) | 受审核 Automation Reconciliation 应用与显式回滚 | Accepted(exact headless 双架构阶段实物已交付) | | [ADR-0528](./ADR-0528-cross-domain-reconciliation-completion-rehearsal.md) | 跨域 Reconciliation Completion 演练 | Accepted(D-426c3 源码候选;双架构阶段实物待 gate) | -| [ADR-0529](./ADR-0529-bounded-read-only-local-panel-cron-adapter.md) | 有界只读 Local 旧面板 Cron Adapter | Proposed(D-427 源码候选;双架构阶段实物待 gate) | +| [ADR-0529](./ADR-0529-bounded-read-only-local-panel-cron-adapter.md) | 有界只读 Local 旧面板 Cron Adapter | Accepted(D-427 双架构阶段实物已交付) | +| [ADR-0530](./ADR-0530-bounded-local-panel-capability-bootstrap.md) | 有界 Local 面板能力发现与启动适配 | Proposed(D-428 源码候选;可下载同源装配与双架构实物待 gate) | ## 规则 diff --git a/packages/ql3-local-api/src/admission/localApiAdmission.ts b/packages/ql3-local-api/src/admission/localApiAdmission.ts index 69565a64..e7d932f3 100644 --- a/packages/ql3-local-api/src/admission/localApiAdmission.ts +++ b/packages/ql3-local-api/src/admission/localApiAdmission.ts @@ -38,6 +38,7 @@ import type { LocalApiSecretPutRoute, } from '../secret/secretRoutes'; import type { PanelCronListRoute } from '../panel-compatibility/panelCronListRoute'; +import type { PanelBootstrapRoute } from '../panel-compatibility/panelBootstrapRoute'; import type { LocalApiResponse } from '../transport/contract'; export type LocalApiAdmissionOperation = @@ -133,6 +134,10 @@ export type LocalApiAdmissionOperation = page: number; size: number; maximumRows: number; + }> + | Readonly<{ + operationId: 'panel.user.get' | 'panel.system.config.get'; + projectId: 'default'; }>; export interface LocalApiAdmissionRequest { @@ -177,6 +182,7 @@ export interface LocalApiAdmissionOptions { readonly secretListRoute: LocalApiSecretListRoute; readonly secretPutRoute: LocalApiSecretPutRoute; readonly panelCronListRoute: PanelCronListRoute; + readonly panelBootstrapRoute: PanelBootstrapRoute; readonly now?: () => number; readonly randomUuid?: () => string; } @@ -263,6 +269,7 @@ export function createLocalApiAdmission( typeof options.secretListRoute?.handle !== 'function' || typeof options.secretPutRoute?.handle !== 'function' || typeof options.panelCronListRoute?.handle !== 'function' || + typeof options.panelBootstrapRoute?.handle !== 'function' || (options.now !== undefined && typeof options.now !== 'function') || (options.randomUuid !== undefined && typeof options.randomUuid !== 'function') @@ -404,7 +411,9 @@ export function createLocalApiAdmission( request.operation.operationId === 'task.get' || request.operation.operationId === 'trigger.list' || request.operation.operationId === 'trigger.get' || - request.operation.operationId === 'panel.cron.list' + request.operation.operationId === 'panel.cron.list' || + request.operation.operationId === 'panel.user.get' || + request.operation.operationId === 'panel.system.config.get' ? 'task.read' : request.operation.operationId === 'secret.list' ? 'secret.manage' @@ -572,6 +581,13 @@ export function createLocalApiAdmission( size: request.operation.size, maximumRows: request.operation.maximumRows, }); + case 'panel.user.get': + case 'panel.system.config.get': + if (body !== null) return response(400, 'invalid_request_body'); + return options.panelBootstrapRoute.handle({ + operationId: request.operation.operationId, + principal: authenticated.principal, + }); case 'task.put': case 'task.authoring': case 'trigger.put': diff --git a/packages/ql3-local-api/src/application-runtime/localApiProductSurface.ts b/packages/ql3-local-api/src/application-runtime/localApiProductSurface.ts index 97e40aa4..25148619 100644 --- a/packages/ql3-local-api/src/application-runtime/localApiProductSurface.ts +++ b/packages/ql3-local-api/src/application-runtime/localApiProductSurface.ts @@ -32,6 +32,7 @@ import { createLocalApiSecretPutRoute, } from '../secret/secretRoutes'; import { createPanelCronListRoute } from '../panel-compatibility/panelCronListRoute'; +import { createPanelBootstrapRoute } from '../panel-compatibility/panelBootstrapRoute'; import { startLocalApiHttpSurface } from '../transport/httpSurface'; export interface LocalApiProductSurfaceEvent { @@ -226,6 +227,7 @@ export function createLocalApiProductSurface( tasks: authority.taskDefinitions, triggers: authority.triggers, }); + const panelBootstrapRoute = createPanelBootstrapRoute(authority.profile); const admission = createLocalApiAdmission({ authenticator, policy, @@ -247,6 +249,7 @@ export function createLocalApiProductSurface( secretListRoute, secretPutRoute, panelCronListRoute, + panelBootstrapRoute, ...(options.now === undefined ? {} : { now: options.now }), ...(options.randomUuid === undefined ? {} diff --git a/packages/ql3-local-api/src/panel-compatibility/panelBootstrapRoute.ts b/packages/ql3-local-api/src/panel-compatibility/panelBootstrapRoute.ts new file mode 100644 index 00000000..3a11e12f --- /dev/null +++ b/packages/ql3-local-api/src/panel-compatibility/panelBootstrapRoute.ts @@ -0,0 +1,169 @@ +import type { LocalApplicationProfile } from '@qinglong/local-application'; +import type { SecurityPrincipal } from '@qinglong/runtime-core/security'; + +import type { LocalApiResponse } from '../transport/contract'; + +const PRODUCT_VERSION = '3.0.0-alpha.2'; + +export type PanelBootstrapOperation = + | 'panel.user.get' + | 'panel.system.config.get'; + +export interface PanelBootstrapRequest { + readonly operationId: PanelBootstrapOperation; + readonly principal: Readonly; +} + +export interface PanelBootstrapRoute { + handle(request: Readonly): Promise; +} + +function response( + statusCode: number, + body: Readonly>, +): Readonly { + return Object.freeze({ statusCode, body: Object.freeze(body) }); +} + +function maximumRows(profile: LocalApplicationProfile): number { + return profile === 'edge' ? 64 : 256; +} + +export function panelCapabilities( + profile: LocalApplicationProfile, +): Readonly> { + if (profile !== 'edge' && profile !== 'standalone') { + throw new TypeError('Panel capability profile is invalid'); + } + const logChunkBytes = profile === 'edge' ? 16 * 1_024 : 32 * 1_024; + return Object.freeze({ + schemaVersion: 1, + product: 'qinglong3', + version: PRODUCT_VERSION, + deployment: Object.freeze({ mode: 'local', profile }), + authentication: Object.freeze({ + kind: 'api_credential', + transport: 'bearer', + persistence: 'memory_only', + loginEndpoint: null, + }), + project: Object.freeze({ selection: 'explicit', defaultId: 'default' }), + panel: Object.freeze({ + bootstrap: true, + cronList: 'bounded_read_only', + taskRead: true, + triggerRead: true, + runRead: true, + runLogRead: true, + legacyMutations: false, + legacyLogin: false, + subscriptions: false, + scripts: false, + environmentVariables: false, + webSocket: false, + }), + limits: Object.freeze({ + cronRows: maximumRows(profile), + cronPageSize: 64, + logChunkBytes, + }), + }); +} + +export function panelPublicResponse( + operation: 'capabilities' | 'health' | 'system', + profile: LocalApplicationProfile, +): Readonly { + const capabilities = panelCapabilities(profile); + if (operation === 'capabilities') { + return response(200, { capabilities }); + } + if (operation === 'health') { + return response(200, { + code: 200, + data: Object.freeze({ + status: 'ok', + ql3: Object.freeze({ + schemaVersion: 1, + apiVersion: 'v3', + capabilitiesPath: '/api/v3/capabilities', + }), + }), + }); + } + return response(200, { + code: 200, + data: Object.freeze({ + branch: 'develop', + isInitialized: true, + publishTime: 0, + version: PRODUCT_VERSION, + changeLog: '', + changeLogLink: '', + ql3: Object.freeze({ + schemaVersion: 1, + mode: 'local', + profile, + capabilitiesPath: '/api/v3/capabilities', + }), + }), + }); +} + +export function createPanelBootstrapRoute( + profile: LocalApplicationProfile, +): Readonly { + if (profile !== 'edge' && profile !== 'standalone') { + throw new TypeError('Panel bootstrap profile is invalid'); + } + return Object.freeze({ + async handle(request: Readonly) { + const principal = request?.principal; + if ( + !principal || + typeof principal !== 'object' || + Array.isArray(principal) || + principal.subject?.type !== 'user' || + typeof principal.subject?.id !== 'string' || + principal.subject.id.length < 1 + ) { + return response(503, { + code: 503, + message: 'QL3 面板身份暂不可用', + }); + } + if (request.operationId === 'panel.user.get') { + return response(200, { + code: 200, + data: Object.freeze({ + username: principal.subject.id, + ql3: Object.freeze({ + schemaVersion: 1, + subjectType: principal.subject.type, + assurance: principal.assurance, + expiresAtMs: principal.expiresAtMs, + credentialPersistence: 'memory_only', + panelHome: '/crontab', + }), + }), + }); + } + if (request.operationId !== 'panel.system.config.get') { + return response(503, { + code: 503, + message: 'QL3 面板启动入口暂不可用', + }); + } + return response(200, { + code: 200, + data: Object.freeze({ + info: Object.freeze({ + panelTitle: 'QingLong 3.0', + lang: 'zh-cn', + }), + ql3: panelCapabilities(profile), + }), + }); + }, + }); +} diff --git a/packages/ql3-local-api/src/panel-compatibility/panelCronListRoute.ts b/packages/ql3-local-api/src/panel-compatibility/panelCronListRoute.ts index 7a46c8a5..d215a490 100644 --- a/packages/ql3-local-api/src/panel-compatibility/panelCronListRoute.ts +++ b/packages/ql3-local-api/src/panel-compatibility/panelCronListRoute.ts @@ -49,7 +49,7 @@ function validRequest(request: Readonly): boolean { Number.isSafeInteger(request.maximumRows) && request.maximumRows >= 1 && request.maximumRows <= 256 && - request.page * request.size <= request.maximumRows + (request.page - 1) * request.size < request.maximumRows ); } @@ -72,7 +72,10 @@ export function createPanelCronListRoute( return response(400, { code: 400, message: '参数错误' }); } try { - const scanLimit = request.page * request.size; + const scanLimit = Math.min( + request.page * request.size, + request.maximumRows, + ); const page = await sources.triggers.listTriggers({ projectId: request.projectId, limit: scanLimit, @@ -149,7 +152,10 @@ export function createPanelCronListRoute( code: 200, data: Object.freeze({ data: Object.freeze(data), - total: page.triggers.length + (page.truncated ? 1 : 0), + total: Math.min( + request.maximumRows, + page.triggers.length + (page.truncated ? 1 : 0), + ), }), }); } catch { diff --git a/packages/ql3-local-api/src/transport/httpSurface.ts b/packages/ql3-local-api/src/transport/httpSurface.ts index 579e2de3..23eb591c 100644 --- a/packages/ql3-local-api/src/transport/httpSurface.ts +++ b/packages/ql3-local-api/src/transport/httpSurface.ts @@ -18,6 +18,7 @@ import { loadLocalConsoleAssets, type LocalConsoleAsset, } from '../console/localConsoleAssets'; +import { panelPublicResponse } from '../panel-compatibility/panelBootstrapRoute'; import type { LocalApiResponse } from './contract'; const MAX_HEADER_BYTES = 8 * 1_024; @@ -65,9 +66,12 @@ type LocalApiRouteResolution = | 'invalid_task_list_query' | 'invalid_trigger_list_query' | 'invalid_secret_list_query' - | 'invalid_panel_cron_list_query'; + | 'invalid_panel_cron_list_query' + | 'invalid_panel_bootstrap_query'; }>; +type PanelPublicOperation = 'capabilities' | 'health' | 'system'; + export interface LocalApiHttpSurfaceOptions { readonly profile: LocalApplicationProfile; readonly host: '127.0.0.1' | '::1'; @@ -589,7 +593,7 @@ function parsePanelCronListRoute( size < 1 || size > 64 || String(size) !== rawSize || - page * size > maximumRows || + (page - 1) * size >= maximumRows || (timestamp !== null && !/^\d{1,20}$/u.test(timestamp)) ) { throw new TypeError(); @@ -606,6 +610,53 @@ function parsePanelCronListRoute( } } +function panelExactGetPath( + rawUrl: string, + expectedPath: string, +): 'invalid' | 'match' | 'unrelated' { + const separator = rawUrl.indexOf('?'); + const path = separator < 0 ? rawUrl : rawUrl.slice(0, separator); + if (path !== expectedPath) return 'unrelated'; + if (separator !== rawUrl.lastIndexOf('?')) return 'invalid'; + if (separator < 0) return 'match'; + try { + const query = new URLSearchParams(rawUrl.slice(separator + 1)); + if ( + [...query.keys()].some( + (key) => key !== 't' || query.getAll(key).length !== 1, + ) + ) { + return 'invalid'; + } + const timestamp = query.get('t'); + return timestamp !== null && /^\d{1,20}$/u.test(timestamp) + ? 'match' + : 'invalid'; + } catch { + return 'invalid'; + } +} + +function panelPublicOperation( + request: IncomingMessage, +): PanelPublicOperation | 'invalid' | null { + if (request.method !== 'GET' || typeof request.url !== 'string') return null; + const paths = Object.freeze([ + Object.freeze({ path: '/api/health', operation: 'health' as const }), + Object.freeze({ path: '/api/system', operation: 'system' as const }), + Object.freeze({ + path: '/api/v3/capabilities', + operation: 'capabilities' as const, + }), + ]); + for (const candidate of paths) { + const result = panelExactGetPath(request.url, candidate.path); + if (result === 'invalid') return 'invalid'; + if (result === 'match') return candidate.operation; + } + return null; +} + function route( request: IncomingMessage, profile: LocalApplicationProfile, @@ -622,6 +673,24 @@ function route( if (request.method === 'GET') { const panelCronList = parsePanelCronListRoute(rawUrl, profile); if (panelCronList) return panelCronList; + const panelUser = panelExactGetPath(rawUrl, '/api/user'); + if (panelUser !== 'unrelated') { + return panelUser === 'match' + ? Object.freeze({ + operationId: 'panel.user.get', + projectId: 'default', + }) + : Object.freeze({ errorCode: 'invalid_panel_bootstrap_query' }); + } + const panelSystemConfig = panelExactGetPath(rawUrl, '/api/system/config'); + if (panelSystemConfig !== 'unrelated') { + return panelSystemConfig === 'match' + ? Object.freeze({ + operationId: 'panel.system.config.get', + projectId: 'default', + }) + : Object.freeze({ errorCode: 'invalid_panel_bootstrap_query' }); + } } if (rawUrl.includes('%')) return null; const separator = rawUrl.indexOf('?'); @@ -940,6 +1009,22 @@ export async function startLocalApiHttpSurface( sendConsoleFavicon(response, requestId); return; } + const publicOperation = panelPublicOperation(request); + if (publicOperation) { + if (hasRequestBody(request)) { + send(response, requestId, errorResponse(400, 'invalid_request_body')); + request.resume(); + return; + } + send( + response, + requestId, + publicOperation === 'invalid' + ? errorResponse(400, 'invalid_panel_bootstrap_query') + : panelPublicResponse(publicOperation, options.profile), + ); + return; + } const resolvedRoute = route(request, options.profile); if (!resolvedRoute) { send(response, requestId, errorResponse(404, 'route_not_found')); diff --git a/packages/ql3-local-api/test/admission.test.cjs b/packages/ql3-local-api/test/admission.test.cjs index 7a2841c3..e67c6ee5 100644 --- a/packages/ql3-local-api/test/admission.test.cjs +++ b/packages/ql3-local-api/test/admission.test.cjs @@ -314,6 +314,17 @@ function fixture(overrides = {}) { }; }, }, + panelBootstrapRoute: { + async handle(value) { + events.push( + `panel-bootstrap:${value.operationId}:${value.principal.subject.id}`, + ); + return { + statusCode: 200, + body: { code: 200, data: { username: value.principal.subject.id } }, + }; + }, + }, now: () => 10_000, randomUuid: () => '019f70c0-0000-4000-8000-000000000002', ...overrides, @@ -548,6 +559,27 @@ test('uses task.read and the exact panel Cron audit identity for the compatibili ]); }); +test('uses the same authenticated task.read chain for the panel bootstrap identity', async () => { + for (const operationId of ['panel.user.get', 'panel.system.config.get']) { + const { admission, events } = fixture(); + const result = await execute( + admission, + request({ + operation: Object.freeze({ operationId, projectId: 'default' }), + }), + ); + assert.equal(result.statusCode, 200); + assert.equal(result.body.data.username, 'usr_local'); + assert.deepEqual(events, [ + 'authenticate', + 'authorize:task.read:default', + `audit:allowed:${operationId}`, + 'confirm', + `panel-bootstrap:${operationId}:usr_local`, + ]); + } +}); + test('authorizes and audits run.stop before exposing the cancellation body handler', async () => { const { admission, events } = fixture(); const prepared = await admission.prepare( diff --git a/packages/ql3-local-api/test/httpSurface.test.cjs b/packages/ql3-local-api/test/httpSurface.test.cjs index 6ade21fd..41d0566f 100644 --- a/packages/ql3-local-api/test/httpSurface.test.cjs +++ b/packages/ql3-local-api/test/httpSurface.test.cjs @@ -277,6 +277,14 @@ test('serves only the fixed canonical loopback Run route and drains idempotently }); assert.deepEqual(observed[6].operation, panelCrons.body.input); + const finalEdgePage = await request( + port, + '/api/crons?searchValue=&page=4&size=20&filters=%7B%7D', + ); + assert.equal(finalEdgePage.statusCode, 200); + assert.equal(finalEdgePage.body.input.maximumRows, 64); + assert.equal(finalEdgePage.body.input.page, 4); + const unsupportedPanelQuery = await request( port, '/api/crons?searchValue=private&page=1&size=20&filters=%7B%7D', @@ -285,7 +293,7 @@ test('serves only the fixed canonical loopback Run route and drains idempotently code: 'invalid_panel_cron_list_query', }); assert.equal(unsupportedPanelQuery.statusCode, 400); - assert.equal(observed.length, 7); + assert.equal(observed.length, 8); const cancellationBody = JSON.stringify({ schema: 'qinglong/run-cancellation@v1', @@ -306,7 +314,7 @@ test('serves only the fixed canonical loopback Run route and drains idempotently ); assert.equal(cancellation.statusCode, 202); assert.deepEqual(cancellation.body.accepted, JSON.parse(cancellationBody)); - assert.deepEqual(observed[7].operation, { + assert.deepEqual(observed[8].operation, { operationId: 'run.cancel', projectId: 'prj_default', runId: 'run_123', @@ -333,7 +341,7 @@ test('serves only the fixed canonical loopback Run route and drains idempotently ); assert.equal(taskStart.statusCode, 202); assert.deepEqual(taskStart.body.accepted, JSON.parse(taskStartBody)); - assert.deepEqual(observed[8].operation, { + assert.deepEqual(observed[9].operation, { operationId: 'task.start', projectId: 'prj_default', taskId: 'task_1', @@ -357,20 +365,20 @@ test('serves only the fixed canonical loopback Run route and drains idempotently ); assert.equal(taskPut.statusCode, 202); assert.deepEqual(taskPut.body.accepted, JSON.parse(taskPutBody)); - assert.deepEqual(observed[9].operation, { + assert.deepEqual(observed[10].operation, { operationId: 'task.put', projectId: 'prj_default', taskId: 'task_1', }); - assert.equal(observed[9].localPresence, 'ql3p_request_bound_proof'); - assert.equal(observed[9].taskAuthoringLease, 'ql3a_exact_snapshot_lease'); + assert.equal(observed[10].localPresence, 'ql3p_request_bound_proof'); + assert.equal(observed[10].taskAuthoringLease, 'ql3a_exact_snapshot_lease'); const log = await request( port, '/api/v3/projects/prj_default/runs/run_123/attempts/attempt_1/log?offset=4&length=32', ); assert.deepEqual(log.body, { range: { offset: 4, length: 32 } }); - assert.deepEqual(observed[10].operation, { + assert.deepEqual(observed[11].operation, { operationId: 'run.log.read', projectId: 'prj_default', runId: 'run_123', @@ -399,19 +407,19 @@ test('serves only the fixed canonical loopback Run route and drains idempotently }, ); assert.equal(authoring.statusCode, 200); - assert.deepEqual(observed[12].operation, { + assert.deepEqual(observed[13].operation, { operationId: 'task.authoring', projectId: 'prj_default', taskId: 'task_1', }); - assert.equal(observed[12].localPresence, 'ql3p_authoring_read_proof'); + assert.equal(observed[13].localPresence, 'ql3p_authoring_read_proof'); const secrets = await request( port, '/api/v3/projects/prj_default/secrets?limit=8&after=YWxwaGE', ); assert.deepEqual(secrets.body, { secrets: [], truncated: false }); - assert.deepEqual(observed[13].operation, { + assert.deepEqual(observed[14].operation, { operationId: 'secret.list', projectId: 'prj_default', limit: 8, @@ -435,11 +443,11 @@ test('serves only the fixed canonical loopback Run route and drains idempotently ); assert.equal(secretPut.statusCode, 202); assert.deepEqual(secretPut.body.accepted, JSON.parse(secretPutBody)); - assert.deepEqual(observed[14].operation, { + assert.deepEqual(observed[15].operation, { operationId: 'secret.put', projectId: 'prj_default', }); - assert.equal(observed[14].localPresence, 'ql3p_secret_bound_proof'); + assert.equal(observed[15].localPresence, 'ql3p_secret_bound_proof'); for (const invalidPath of [ '/api/v3/projects/prj_default/runs/run_123?expanded=true', @@ -521,13 +529,86 @@ test('serves only the fixed canonical loopback Run route and drains idempotently assert.equal(invalid.statusCode, 400); assert.deepEqual(invalid.body, { code: 'invalid_run_step_list_query' }); } - assert.equal(observed.length, 15); + assert.equal(observed.length, 16); assert.deepEqual( await Promise.all([surface.stopAndDrain(), surface.stopAndDrain()]), ['stopped', 'stopped'], ); }); +test('serves the public capability shell and authenticates private panel bootstrap reads', async (t) => { + const port = await reservePort(); + const observed = []; + const surface = await startLocalApiHttpSurface({ + profile: 'edge', + host: '127.0.0.1', + port, + admission: preparedAdmission(async (value) => { + observed.push(value); + return { + statusCode: 200, + body: { + code: 200, + data: { operationId: value.operation.operationId }, + }, + }; + }), + randomUuid: () => '019f70c0-0000-4000-8000-000000000013', + }); + t.after(() => surface.stopAndDrain()); + + const health = await request(port, '/api/health?t=100', { headers: {} }); + assert.equal(health.statusCode, 200); + assert.equal(health.body.data.status, 'ok'); + assert.equal(health.body.data.ql3.capabilitiesPath, '/api/v3/capabilities'); + + const system = await request(port, '/api/system', { headers: {} }); + assert.equal(system.statusCode, 200); + assert.equal(system.body.data.isInitialized, true); + assert.equal(system.body.data.ql3.profile, 'edge'); + + const capabilities = await request(port, '/api/v3/capabilities?t=101', { + headers: {}, + }); + assert.equal(capabilities.statusCode, 200); + assert.equal( + capabilities.body.capabilities.authentication.loginEndpoint, + null, + ); + assert.equal(capabilities.body.capabilities.panel.legacyLogin, false); + assert.equal(capabilities.body.capabilities.limits.cronRows, 64); + assert.equal(observed.length, 0); + + const user = await request(port, '/api/user?t=102'); + assert.deepEqual(user.body, { + code: 200, + data: { operationId: 'panel.user.get' }, + }); + assert.deepEqual(observed[0].operation, { + operationId: 'panel.user.get', + projectId: 'default', + }); + + const config = await request(port, '/api/system/config?t=103'); + assert.deepEqual(config.body, { + code: 200, + data: { operationId: 'panel.system.config.get' }, + }); + assert.deepEqual(observed[1].operation, { + operationId: 'panel.system.config.get', + projectId: 'default', + }); + + const invalid = await request(port, '/api/system?search=wide', { + headers: {}, + }); + assert.equal(invalid.statusCode, 400); + assert.deepEqual(invalid.body, { code: 'invalid_panel_bootstrap_query' }); + const encoded = await request(port, '/api/%73ystem', { headers: {} }); + assert.equal(encoded.statusCode, 404); + assert.equal(observed.length, 2); +}); + test('rejects GET bodies without invoking the prepared route handler', async (t) => { const port = await reservePort(); let handlers = 0; diff --git a/packages/ql3-local-api/test/panelBootstrapRoute.test.cjs b/packages/ql3-local-api/test/panelBootstrapRoute.test.cjs new file mode 100644 index 00000000..235f9679 --- /dev/null +++ b/packages/ql3-local-api/test/panelBootstrapRoute.test.cjs @@ -0,0 +1,150 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + createPanelBootstrapRoute, + panelCapabilities, + panelPublicResponse, +} = require('../dist/panel-compatibility/panelBootstrapRoute.js'); + +const PRINCIPAL = Object.freeze({ + subject: Object.freeze({ type: 'user', id: 'owner' }), + authenticationId: 'auth:owner', + authenticatedAtMs: 1_787_200_000_000, + expiresAtMs: 1_787_200_060_000, + assurance: 'local_console', +}); + +test('publishes an exact profile-aware capability contract', () => { + const edge = panelCapabilities('edge'); + const standalone = panelCapabilities('standalone'); + assert.deepEqual(edge, { + schemaVersion: 1, + product: 'qinglong3', + version: '3.0.0-alpha.2', + deployment: { mode: 'local', profile: 'edge' }, + authentication: { + kind: 'api_credential', + transport: 'bearer', + persistence: 'memory_only', + loginEndpoint: null, + }, + project: { selection: 'explicit', defaultId: 'default' }, + panel: { + bootstrap: true, + cronList: 'bounded_read_only', + taskRead: true, + triggerRead: true, + runRead: true, + runLogRead: true, + legacyMutations: false, + legacyLogin: false, + subscriptions: false, + scripts: false, + environmentVariables: false, + webSocket: false, + }, + limits: { + cronRows: 64, + cronPageSize: 64, + logChunkBytes: 16 * 1_024, + }, + }); + assert.deepEqual(standalone.limits, { + cronRows: 256, + cronPageSize: 64, + logChunkBytes: 32 * 1_024, + }); + assert.throws(() => panelCapabilities('cluster')); +}); + +test('serves public health, system and native capability envelopes', () => { + assert.deepEqual(panelPublicResponse('health', 'edge'), { + statusCode: 200, + body: { + code: 200, + data: { + status: 'ok', + ql3: { + schemaVersion: 1, + apiVersion: 'v3', + capabilitiesPath: '/api/v3/capabilities', + }, + }, + }, + }); + const system = panelPublicResponse('system', 'standalone'); + assert.equal(system.body.code, 200); + assert.equal(system.body.data.isInitialized, true); + assert.equal(system.body.data.version, '3.0.0-alpha.2'); + assert.equal(system.body.data.ql3.profile, 'standalone'); + const capabilities = panelPublicResponse('capabilities', 'edge'); + assert.equal(capabilities.body.capabilities.limits.cronRows, 64); +}); + +test('projects the authenticated principal and bounded panel configuration', async () => { + const route = createPanelBootstrapRoute('edge'); + const user = await route.handle({ + operationId: 'panel.user.get', + principal: PRINCIPAL, + }); + assert.deepEqual(user, { + statusCode: 200, + body: { + code: 200, + data: { + username: 'owner', + ql3: { + schemaVersion: 1, + subjectType: 'user', + assurance: 'local_console', + expiresAtMs: 1_787_200_060_000, + credentialPersistence: 'memory_only', + panelHome: '/crontab', + }, + }, + }, + }); + const config = await route.handle({ + operationId: 'panel.system.config.get', + principal: PRINCIPAL, + }); + assert.equal(config.statusCode, 200); + assert.deepEqual(config.body.data.info, { + panelTitle: 'QingLong 3.0', + lang: 'zh-cn', + }); + assert.equal(config.body.data.ql3.limits.cronRows, 64); +}); + +test('fails closed for invalid profile, principal and operation', async () => { + assert.throws(() => createPanelBootstrapRoute('cluster')); + const route = createPanelBootstrapRoute('standalone'); + assert.equal( + (await route.handle({ operationId: 'panel.user.get', principal: null })) + .statusCode, + 503, + ); + assert.equal( + ( + await route.handle({ + operationId: 'panel.user.get', + principal: { + ...PRINCIPAL, + subject: { type: 'api_app', id: 'service' }, + assurance: 'service', + }, + }) + ).statusCode, + 503, + ); + assert.equal( + ( + await route.handle({ + operationId: 'panel.unknown', + principal: PRINCIPAL, + }) + ).statusCode, + 503, + ); +}); diff --git a/packages/ql3-local-api/test/panelCronListRoute.test.cjs b/packages/ql3-local-api/test/panelCronListRoute.test.cjs index da6d8147..7edf83d6 100644 --- a/packages/ql3-local-api/test/panelCronListRoute.test.cjs +++ b/packages/ql3-local-api/test/panelCronListRoute.test.cjs @@ -230,6 +230,18 @@ test('fails closed for detached pins, unsupported triggers, invalid budgets and }, }, }); + assert.deepEqual( + await route.handle({ + projectId: 'default', + page: 4, + size: 20, + maximumRows: 64, + }), + { + statusCode: 200, + body: { code: 200, data: { data: [], total: 0 } }, + }, + ); assert.equal( ( await route.handle({ diff --git a/packages/ql3-local-api/test/sqliteHttp.integration.test.cjs b/packages/ql3-local-api/test/sqliteHttp.integration.test.cjs index e87a8a10..796e0872 100644 --- a/packages/ql3-local-api/test/sqliteHttp.integration.test.cjs +++ b/packages/ql3-local-api/test/sqliteHttp.integration.test.cjs @@ -462,6 +462,34 @@ test('serves an authenticated Run through one real SQLite authority and durable }); t.after(() => active.stopAndDrain()); + const health = await request( + port, + 'Bearer ignored-public-credential', + '/api/health?t=100', + ); + assert.equal(health.statusCode, 200); + assert.equal(health.body.data.status, 'ok'); + const capabilities = await request( + port, + 'Bearer ignored-public-credential', + '/api/v3/capabilities', + ); + assert.equal(capabilities.statusCode, 200); + assert.equal(capabilities.body.capabilities.deployment.profile, 'edge'); + assert.equal(capabilities.body.capabilities.panel.legacyMutations, false); + const panelUser = await request(port, `Bearer ${TOKEN}`, '/api/user?t=101'); + assert.equal(panelUser.statusCode, 200); + assert.equal(panelUser.body.data.username, 'local-api-user'); + assert.equal(panelUser.body.data.ql3.credentialPersistence, 'memory_only'); + const panelConfig = await request( + port, + `Bearer ${TOKEN}`, + '/api/system/config?t=102', + ); + assert.equal(panelConfig.statusCode, 200); + assert.equal(panelConfig.body.data.info.panelTitle, 'QingLong 3.0'); + assert.equal(panelConfig.body.data.ql3.limits.cronRows, 64); + const accepted = await request(port, `Bearer ${TOKEN}`); assert.equal(accepted.statusCode, 200); assert.equal(accepted.body.run.id, RUN_ID); @@ -1132,6 +1160,7 @@ test('serves an authenticated Run through one real SQLite authority and durable 'run.get', 'run.list', 'run.events.list', 'run.steps.list', 'run.cancel', 'task.authoring.read', 'task.create', 'task.get', 'task.list', 'task.start', 'task.update', 'run.log.read', + 'panel.system.config.get', 'panel.user.get', 'trigger.create', 'trigger.get', 'trigger.list', 'trigger.update', 'secret.create', 'secret.list' ) @@ -1140,6 +1169,8 @@ test('serves an authenticated Run through one real SQLite authority and durable .all() .map(({ operation_id, outcome }) => `${operation_id}:${outcome}`), [ + 'panel.system.config.get:allowed', + 'panel.user.get:allowed', 'run.cancel:allowed', 'run.cancel:allowed', 'run.events.list:allowed', diff --git a/src/layouts/index.tsx b/src/layouts/index.tsx index f8e5c1ec..d851732c 100644 --- a/src/layouts/index.tsx +++ b/src/layouts/index.tsx @@ -18,6 +18,13 @@ import defaultProps from './defaultProps'; import './index.less'; import { init } from '../utils/init'; import WebSocketManager from '../utils/websocket'; +import { + clearQingLong3Credential, + discoverQingLong3, + isQingLong3PanelSession, + qingLong3Capabilities, + qingLong3Credential, +} from '../utils/qinglong3'; export interface SharedContext { headerStyle: React.CSSProperties; @@ -38,6 +45,12 @@ interface TSystemInfo { version: string; changeLog: string; changeLogLink: string; + ql3?: { + schemaVersion: 1; + mode: 'local'; + profile: 'edge' | 'standalone'; + capabilitiesPath: string; + }; } export default function () { @@ -48,7 +61,8 @@ export default function () { const [loading, setLoading] = useState(true); const [systemInfo, setSystemInfo] = useState(); const [siteTitle, setSiteTitle] = useState( - () => localStorage.getItem('qinglong_panel_title')?.trim() || intl.get('青龙'), + () => + localStorage.getItem('qinglong_panel_title')?.trim() || intl.get('青龙'), ); const [collapsed, setCollapsed] = useState(false); const [initLoading, setInitLoading] = useState(true); @@ -61,6 +75,12 @@ export default function () { } = DarkReader || {}; const logout = () => { + if (isQingLong3PanelSession()) { + clearQingLong3Credential(); + setUser({}); + history.push('/login'); + return; + } request.post(`${config.apiPrefix}user/logout`).then(() => { localStorage.removeItem(config.authKey); history.push('/login'); @@ -70,14 +90,27 @@ export default function () { const getSystemInfo = () => { request .get(`${config.apiPrefix}system`) - .then(({ code, data }) => { + .then(async ({ code, data }) => { if (code === 200) { + let qingLong3 = null; + if (data?.ql3?.capabilitiesPath === '/api/v3/capabilities') { + qingLong3 = await discoverQingLong3( + `${config.apiPrefix}v3/capabilities`, + ); + } setSystemInfo(data); if (!data.isInitialized) { history.push('/initialization'); } else { init(data.version); - getUser(); + if (!qingLong3 || qingLong3Credential()) { + getUser(); + } else { + setLoading(false); + if (!['/login', '/error'].includes(location.pathname)) { + history.replace('/login'); + } + } } } }) @@ -94,7 +127,7 @@ export default function () { if (code === 200 && data.username) { setUser(data); if (location.pathname === '/') { - history.push('/dashboard'); + history.push(data?.ql3?.panelHome || '/dashboard'); } } needLoading && setLoading(false); @@ -165,8 +198,10 @@ export default function () { }, []); useEffect(() => { + if (!systemInfo) return; + if (systemInfo.ql3 && !isQingLong3PanelSession()) return; reloadSystemConfig(); - }, []); + }, [systemInfo]); useEffect(() => { if (!['/login', '/initialization', '/error'].includes(location.pathname)) { @@ -210,7 +245,7 @@ export default function () { }, []); useEffect(() => { - if (!user || !user.username) return; + if (!user || !user.username || isQingLong3PanelSession()) return; const ws = WebSocketManager.getInstance( `${window.location.origin}${ config.apiPrefix @@ -222,6 +257,16 @@ export default function () { }; }, [user]); + useEffect(() => { + if ( + isQingLong3PanelSession() && + user?.username && + !['/login', '/error', '/crontab'].includes(location.pathname) + ) { + history.replace('/crontab'); + } + }, [location.pathname, user]); + useEffect(() => { window.onload = () => { const timing = performance.timing; @@ -244,7 +289,7 @@ export default function () { if (['/login', '/initialization', '/error'].includes(location.pathname)) { if (systemInfo?.isInitialized && location.pathname === '/initialization') { - history.push('/dashboard'); + history.push(qingLong3Capabilities() ? '/crontab' : '/dashboard'); } if (systemInfo || location.pathname === '/error') { @@ -282,6 +327,17 @@ export default function () { }, ], }; + const layoutProps = isQingLong3PanelSession() + ? { + ...defaultProps, + route: { + ...defaultProps.route, + routes: defaultProps.route?.routes?.filter((item) => + ['/login', '/error', '/crontab'].includes(item.path || ''), + ), + }, + } + : defaultProps; return loading ? ( ) : ( @@ -393,7 +449,7 @@ export default function () { )} - {...defaultProps} + {...layoutProps} > { const { headerStyle, isPhone, theme } = useOutletContext(); + const qingLong3 = qingLong3Capabilities(); + const qingLong3ReadOnly = qingLong3 !== null; + const qingLong3Authenticated = isQingLong3PanelSession(); const [allSubscriptions, setAllSubscriptions] = useState([]); const columns: ColumnProps[] = [ { @@ -79,16 +86,21 @@ const Crontab = () => { style={{ wordBreak: 'break-all', marginBottom: 0, - color: '#1890ff', - cursor: 'pointer', + color: qingLong3ReadOnly ? undefined : '#1890ff', + cursor: qingLong3ReadOnly ? 'default' : 'pointer', }} ellipsis={{ tooltip: text, rows: 2 }} onClick={() => { + if (qingLong3ReadOnly) return; setDetailCron(record); setIsDetailModalVisible(true); }} > - {record.name || '-'} + {qingLong3ReadOnly ? ( + record.name || '-' + ) : ( + {record.name || '-'} + )} ), sorter: { @@ -109,13 +121,17 @@ const Crontab = () => { }} ellipsis={{ tooltip: text, rows: 2 }} > - { - goToScriptManager(record); - }} - > - {text} - + {qingLong3ReadOnly ? ( + text + ) : ( + { + goToScriptManager(record); + }} + > + {text} + + )} ); }, @@ -248,8 +264,8 @@ const Crontab = () => { > {record.last_execution_time ? dayjs(record.last_execution_time * 1000).format( - 'YYYY-MM-DD HH:mm:ss', - ) + 'YYYY-MM-DD HH:mm:ss', + ) : '-'} ); @@ -354,6 +370,19 @@ const Crontab = () => { const tableRef = useRef(null); const tableScrollHeight = useTableScrollHeight(tableRef); const [activeKey, setActiveKey] = useState(''); + const visibleColumns = qingLong3ReadOnly + ? columns + .filter((column) => + ['name', 'command', 'status', 'schedule'].includes( + String(column.key || ''), + ), + ) + .map((column) => ({ + ...column, + sorter: undefined, + filters: undefined, + })) + : columns; const goToScriptManager = (record: any) => { const result = getCommandScript(record.command); @@ -366,12 +395,20 @@ const Crontab = () => { }; const getCrons = async (silent?: boolean) => { + if (qingLong3ReadOnly && !qingLong3Authenticated) { + if (!silent) setLoading(false); + return; + } if (!silent) setLoading(true); const { page = 1, size = 20, sorter, filters = {} } = pageConf; - let url = `${config.apiPrefix - }crons?searchValue=${searchText}&page=${page}&size=${size}&filters=${JSON.stringify( - filters, - )}`; + const effectiveSize = qingLong3ReadOnly + ? Math.min(size, qingLong3?.limits.cronPageSize || 20) + : size; + const effectiveSearchText = qingLong3ReadOnly ? '' : searchText; + const serializedFilters = qingLong3ReadOnly + ? encodeURIComponent('{}') + : JSON.stringify(filters); + let url = `${config.apiPrefix}crons?searchValue=${effectiveSearchText}&page=${page}&size=${effectiveSize}&filters=${serializedFilters}`; if (sorter && sorter.column && sorter.order) { url += `&sorter=${JSON.stringify({ field: sorter.column.key, @@ -390,6 +427,11 @@ const Crontab = () => { .then(async ({ code, data: _data }) => { if (code === 200) { const { data, total } = _data; + if (qingLong3ReadOnly) { + setValue(data); + setTotal(total); + return; + } const subscriptions = await request.get( `${config.apiPrefix}subscriptions?ids=${JSON.stringify([ ...new Set(data.map((x) => x.sub_id).filter(Boolean)), @@ -544,8 +586,9 @@ const Crontab = () => { const enabledOrDisabledCron = (record: any, index: number) => { Modal.confirm({ - title: `确认${record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用') - }`, + title: `确认${ + record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用') + }`, content: ( <> {intl.get('确认')} @@ -560,7 +603,8 @@ const Crontab = () => { onOk() { request .put( - `${config.apiPrefix}crons/${record.isDisabled === 1 ? 'enable' : 'disable' + `${config.apiPrefix}crons/${ + record.isDisabled === 1 ? 'enable' : 'disable' }`, [record.id], ) @@ -584,8 +628,9 @@ const Crontab = () => { const pinOrUnPinCron = (record: any, index: number) => { Modal.confirm({ - title: `确认${record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶') - }`, + title: `确认${ + record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶') + }`, content: ( <> {intl.get('确认')} @@ -600,7 +645,8 @@ const Crontab = () => { onOk() { request .put( - `${config.apiPrefix}crons/${record.isPinned === 1 ? 'unpin' : 'pin' + `${config.apiPrefix}crons/${ + record.isPinned === 1 ? 'unpin' : 'pin' }`, [record.id], ) @@ -777,8 +823,8 @@ const Crontab = () => { setPageConf({ page: current as number, size: pageSize as number, - sorter, - filters, + sorter: qingLong3ReadOnly ? {} : sorter, + filters: qingLong3ReadOnly ? {} : filters, }); localStorage.setItem('pageSize', String(pageSize)); }; @@ -824,10 +870,20 @@ const Crontab = () => { setAllSubscriptions(data || []); } }) - .catch(() => { }); + .catch(() => {}); }; useEffect(() => { + if (qingLong3ReadOnly) { + setPageConf({ + page: 1, + size: Math.min(20, qingLong3?.limits.cronPageSize || 20), + sorter: {}, + filters: {}, + }); + setActiveKey('all'); + return; + } getCronViews(); getAllSubscriptions(); }, []); @@ -931,56 +987,63 @@ const Crontab = () => { setSearchValue(e.target.value)} - onSearch={onSearch} - />, - , - ]} + extra={ + qingLong3ReadOnly + ? [QingLong 3.0 · 有界只读] + : [ + setSearchValue(e.target.value)} + onSearch={onSearch} + />, + , + ] + } header={{ style: headerStyle, }} > - -
- - {intl.get('更多')} - - -
-
- - } - onTabClick={tabClick} - items={[ - ...[...enabledCronViews].slice(0, SHOW_TAB_COUNT).map((x) => ({ - key: x.id, - label: x.name, - })), - ]} - /> + {!qingLong3ReadOnly && ( + +
+ + {intl.get('更多')} + + +
+
+ + } + onTabClick={tabClick} + items={[ + ...[...enabledCronViews].slice(0, SHOW_TAB_COUNT).map((x) => ({ + key: x.id, + label: x.name, + })), + ]} + /> + )}
- {selectedRowIds.length > 0 && ( + {!qingLong3ReadOnly && selectedRowIds.length > 0 && (
)} { total, showTotal: (total: number, range: number[]) => `第 ${range[0]}-${range[1]} 条/总共 ${total} 条`, - pageSizeOptions: [10, 20, 50, 100, 200, 500, total || 10000].sort( - (a, b) => a - b, - ), + pageSizeOptions: qingLong3ReadOnly + ? [10, 20, 50, qingLong3?.limits.cronPageSize || 64] + : [10, 20, 50, 100, 200, 500, total || 10000].sort( + (a, b) => a - b, + ), }} dataSource={value} rowKey="id" size="middle" scroll={{ x: 1200, y: tableScrollHeight }} loading={loading} - rowSelection={rowSelection} + rowSelection={qingLong3ReadOnly ? undefined : rowSelection} rowClassName={getRowClassName} onChange={onPageChange} components={isPhone || pageConf.size < 50 ? undefined : vt} /> - {isLogModalVisible && ( + {!qingLong3ReadOnly && isLogModalVisible && ( { getCronDetail(logCron); @@ -1076,10 +1141,10 @@ const Crontab = () => { cron={logCron} /> )} - {isModalVisible && ( + {!qingLong3ReadOnly && isModalVisible && ( )} - {isLabelModalVisible && ( + {!qingLong3ReadOnly && isLabelModalVisible && ( { setIsLabelModalVisible(false); @@ -1090,7 +1155,7 @@ const Crontab = () => { ids={selectedRowIds} /> )} - {isDetailModalVisible && ( + {!qingLong3ReadOnly && isDetailModalVisible && ( { setIsDetailModalVisible(false); @@ -1103,7 +1168,7 @@ const Crontab = () => { isPhone={isPhone} /> )} - {isCreateViewModalVisible && ( + {!qingLong3ReadOnly && isCreateViewModalVisible && ( { setIsCreateViewModalVisible(false); @@ -1111,7 +1176,7 @@ const Crontab = () => { }} /> )} - {isViewManageModalVisible && ( + {!qingLong3ReadOnly && isViewManageModalVisible && ( { diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx index 8b0a0d50..2eacaeb6 100644 --- a/src/pages/login/index.tsx +++ b/src/pages/login/index.tsx @@ -17,24 +17,59 @@ import { useTheme } from '@/utils/hooks'; import { MobileOutlined } from '@ant-design/icons'; import { SharedContext } from '@/layouts'; import dayjs from 'dayjs'; +import { + clearQingLong3Credential, + qingLong3Capabilities, + qingLong3Credential, + setQingLong3Credential, +} from '@/utils/qinglong3'; const FormItem = Form.Item; const { Countdown } = Statistic; const isDemoEnv = window.__ENV__DeployEnv === 'demo'; const Login = () => { - const { reloadUser } = useOutletContext(); + const { reloadSystemConfig, reloadUser } = useOutletContext(); const [loading, setLoading] = useState(false); const [waitTime, setWaitTime] = useState(); const { theme } = useTheme(); const [twoFactor, setTwoFactor] = useState(false); const [verifying, setVerifying] = useState(false); const [loginInfo, setLoginInfo] = useState(); + const qingLong3 = qingLong3Capabilities(); const handleOk = (values: any) => { setLoading(true); setTwoFactor(false); setWaitTime(null); + if (qingLong3) { + const token = String(values.credential || '').trim(); + if (!setQingLong3Credential(token)) { + message.error('API Credential 格式无效'); + setLoading(false); + return; + } + request + .get(`${config.apiPrefix}user`) + .then(({ code, data }: any) => { + if (code !== 200 || !data?.username) { + throw new TypeError('QL3 identity is unavailable'); + } + notification.success({ + message: 'QingLong 3.0 已连接', + description: `${data.username} · ${qingLong3.deployment.profile}`, + }); + reloadSystemConfig(); + reloadUser(true); + history.push('/crontab'); + }) + .catch(() => { + clearQingLong3Credential(); + message.error('API Credential 验证失败'); + }) + .finally(() => setLoading(false)); + return; + } request .post(`${config.apiPrefix}user/login`, { username: values.username, @@ -134,11 +169,13 @@ const Login = () => { }; useEffect(() => { - const isAuth = localStorage.getItem(config.authKey); + const isAuth = qingLong3 + ? qingLong3Credential() + : localStorage.getItem(config.authKey); if (isAuth) { - history.push('/dashboard'); + history.push(qingLong3 ? '/crontab' : '/dashboard'); } - }, []); + }, [qingLong3]); return (
@@ -150,7 +187,11 @@ const Login = () => { src="https://qn.whyour.cn/logo.png" /> - {twoFactor ? intl.get('两步验证') : config.siteName} + {twoFactor + ? intl.get('两步验证') + : qingLong3 + ? 'QingLong 3.0' + : config.siteName}
@@ -186,20 +227,44 @@ const Login = () => { ) : (
- - - - - - + {qingLong3 ? ( + + + + ) : ( + <> + + + + + + + + )} {waitTime ? (