commit 5feae3ad41fed3c4cd269af3603c8ee0fbcd012b Author: Qiufeng Date: Thu Aug 27 23:36:08 2026 +0800 chore: initialize standalone business plugin repository diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..24f7441 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: Business Plugins CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + strategy: + matrix: + plugin: + - plugin-admin + - subscription-admin + defaults: + run: + working-directory: plugins/${{ matrix.plugin }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.23' + - uses: actions/setup-node@v4 + with: + node-version: '20' + - run: go test -race ./... -count=1 + - run: go vet ./... + - run: node --check ui/app.js + - run: sh -n build.sh + - if: matrix.plugin == 'subscription-admin' + run: | + sh -n package.sh + go run ./tools/manifestcheck diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1f60577 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Secrets and local configuration +.env +.env.* +!.env.example + +# Build output and runtime data +bin/ +dist/ +build/ +data/ +.cache/ +*.log +*.test +coverage.out + +# OS/editor files +.DS_Store +.idea/ +.vscode/ +*.swp + +# Browser test output +playwright-report/ +test-results/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..153d416 --- /dev/null +++ b/LICENSE @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..92c503b --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +.PHONY: test vet check build package + +test: + (cd plugins/plugin-admin && go test -race ./... -count=1) + (cd plugins/subscription-admin && go test -race ./... -count=1) + +vet: + (cd plugins/plugin-admin && go vet ./...) + (cd plugins/subscription-admin && go vet ./...) + +check: test vet + node --check plugins/plugin-admin/ui/app.js + node --check plugins/subscription-admin/ui/app.js + sh -n plugins/plugin-admin/build.sh plugins/plugin-admin/test/run-browser-check.sh + sh -n plugins/subscription-admin/build.sh plugins/subscription-admin/package.sh plugins/subscription-admin/test/run-browser-check.sh + (cd plugins/subscription-admin && go run ./tools/manifestcheck) + git diff --check + +build: + (cd plugins/plugin-admin && ./build.sh) + (cd plugins/subscription-admin && ./build.sh) + +package: + (cd plugins/subscription-admin && ./package.sh) diff --git a/README.md b/README.md new file mode 100644 index 0000000..f8bfb92 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# Sub2API Business Plugins + +独立的 Sub2API 业务插件仓库。插件作为独立服务运行,通过 Sub2API 的公开 +HTTP API、管理员鉴权和 `custom_menu_items` 接入 Core;插件不导入 Core +源码、不连接 Core 数据库,也不修改 Core 的 Go、Vue、迁移或现有 +`.s2plugin` transport ABI。 + +## 目录 + +- `plugins/plugin-admin`:通用插件管理控制面,负责清单、签名、安装、启用、 + 停用、升级、回滚、卸载、配置、健康检查、审计和菜单注入。 +- `plugins/subscription-admin`:可选的订阅管理业务插件。它不是插件管理 + 控制面,只有安装、启用并应用菜单后才会出现。 +- `docs/`:插件框架、清单、边界、架构、开发和验收契约。 + +两个插件都是独立 Go module,可以分别构建和发布。生产环境应使用独立的 +低权限服务账号、HTTPS 反向代理、签名包和稳定的 Core API 兼容基线。 + +## 快速验证 + +```sh +(cd plugins/plugin-admin && go test -race ./... && go vet ./...) +(cd plugins/subscription-admin && go test -race ./... && go vet ./...) +(cd plugins/subscription-admin && go run ./tools/manifestcheck) +``` + +生成订阅插件包: + +```sh +(cd plugins/subscription-admin && ./package.sh) +``` + +构建脚本只生成本地二进制或 `dist/` 包,不将它们提交到仓库。 + +## 接入顺序 + +1. 启动 `plugin-admin` 和需要的业务插件,各自监听独立端口。 +2. 使用 Core 管理员账号登录插件服务;普通账号被拒绝。 +3. 在 `plugin-admin` 上传并校验业务插件包,配置 loopback `service_url`。 +4. 启用插件并完成健康检查。 +5. 预览、确认并应用插件声明的管理员菜单。 + +Core 继续作为用户、余额、订阅、计费和用量账本的权威来源。插件浏览器端 +不持有 Core JWT、Admin Key 或其他服务密钥。 diff --git a/docs/BUSINESS_PLUGIN_ACCEPTANCE.md b/docs/BUSINESS_PLUGIN_ACCEPTANCE.md new file mode 100644 index 0000000..dc2240b --- /dev/null +++ b/docs/BUSINESS_PLUGIN_ACCEPTANCE.md @@ -0,0 +1,61 @@ +# Business Plugin V1 验收矩阵 + +| ID | 类别 | 验收项 | 预期证据 | 状态 | +|---|---|---|---|---| +| AUTH-01 | 鉴权 | Core 管理员登录控制面 | `plugins/plugin-admin/main_test.go:TestAdminLoginDoesNotExposeCoreTokens`;本地浏览器登录 | passed | +| AUTH-02 | 鉴权 | Core 2FA 登录 | challenge 一次性消费,成功创建会话 | passed | +| AUTH-03 | 鉴权 | 普通用户登录和 API | `plugins/plugin-admin/main_test.go:TestOrdinaryCoreUserIsRejected` | passed | +| AUTH-04 | 会话 | 过期、撤销、登出和刷新 | `plugins/plugin-admin/main_test.go:TestRefreshRevalidatesAdminRole` | passed | +| AUTH-05 | CSRF | 所有写请求 | `plugins/plugin-admin/main_test.go:TestMutationRequiresCSRFAndIdempotency` | passed | +| SEC-01 | 秘密 | 浏览器、URL、HTML、JS、LocalStorage、下载、日志 | 登录/配置测试断言 token 和 secret 不回显;浏览器 DOM 未出现 Core token | passed | +| SEC-02 | 出站 | Core URL、重定向、代理和 SSRF | `TestHealthProbeRejectsRedirectAndRequiresReadiness`;loopback URL 校验 | passed | +| SEC-03 | 脱敏 | Core 响应和错误 | token/password/secret/cookie 不出现在响应和日志 | passed | +| MAN-01 | 清单 | 未知字段、尾随 JSON、路径跳转 | `plugins/plugin-admin/internal/manifest/manifest_test.go`;包上传 smoke | passed | +| MAN-02 | 签名 | Ed25519、key ID、哈希 | `manifest_test.go:TestSignatureAndKeyID`;生产不受信发布者路径 | passed | +| MAN-03 | 兼容 | Core baseline、tested versions、capability | `manifest_test.go:TestCompatibility`;上传卡片显示 compatible | passed | +| LIFE-01 | 安装 | staging、原子切换、失败回滚 | `main_test.go:TestPackageInspectionAndAtomicInstall`;真实上传后 active revision 可见 | passed | +| LIFE-02 | 启停 | enable/disable/drain | 停用路径有 SIGTERM + drain 超时逻辑;进程组清理和外部服务不误停 | passed | +| LIFE-03 | 升级 | 新 revision 健康后切换 | 失败升级保留 active;模式切换不继承端点;成功提交后才切换进程 | passed | +| LIFE-04 | 卸载 | 先停用再卸载 | 先提交注册表删除,成功后再清理插件资源,不删除 Core 数据 | passed | +| MENU-01 | 菜单 | preview/apply 自有 `custom_menu_items` | `main_test.go:TestMenuPreviewAndApplyPreserveOtherMenuItems` | passed | +| MENU-02 | 嵌入 | iframe 和新窗口 | 本地控制面三视口登录/刷新;插件提供独立登录和新窗口入口 | passed | +| API-01 | allowlist | 未声明路径和查询参数 | `allowedCorePath` 单元路径门禁;业务插件自身 allowlist 测试 | passed | +| API-02 | Core 错误 | 401/403/409/429/5xx | 失败关闭、刷新一次、错误脱敏和请求 ID 传播 | passed | +| UI-01 | 响应式 | 425px、900px、1440px | 本地 Browser 验收:三个视口 `scrollWidth == innerWidth`,插件卡片可见 | passed | +| OPS-01 | 健康 | healthz/readyz、版本和 request ID | 控制面 HTTP smoke + 插件清单检查;健康/就绪响应含版本 | passed | +| OPS-02 | 权限 | 低权限账号、secret 文件和网络 | systemd 示例使用低权限账号、禁止提权、限制读写目录 | passed | +| REG-01 | 重建 | 清空 projection/cache | 控制面注册表可从磁盘恢复;健康 command/external 插件启动时重探 | passed | +| REG-02 | 兼容 | Core 升级/降级和旧插件 | 未测试版本保持 disabled,启动恢复再次检查 baseline | passed | + +## 命令门禁 + +控制面和每个业务插件至少执行: + +```sh +go test ./... -count=1 +go vet ./... +node --check +production build +manifest verification +git diff --check +``` + +浏览器验收必须保存三种视口截图、网络敏感字段扫描结果、iframe/新窗口登录结果、刷新恢复、停用、升级和回滚证据。Mock Core 只能证明契约;具备测试环境时必须追加真实 Core 登录、2FA、权限、分页和错误联调。 + +## 本轮证据 + +- `plugins/plugin-admin` 和 `plugins/subscription-admin`:`go test -race ./...`、`go vet ./...`、`node --check ui/app.js` 均通过。 +- `plugins/subscription-admin/package.sh` 生成的 `.s2plugin` 已通过 `unzip -t`,并通过控制面真实上传接口进入 `disabled` 状态。 +- 本地浏览器登录后,控制面首页显示“已登记插件”与订阅插件卡片;425、900、1440 视口均无横向溢出。 +- 仍需部署环境追加:真实生产签名密钥、跨实例共享会话、真实 Core iframe 刷新和跨节点升级演练;这些属于部署级验证,不改变本地 V1 控制面契约。 + +截图证据保存在 `.playwright-cli/plugin-admin-v1-final/`、`.playwright-cli/plugin-admin-v1-sensitive/`、`.playwright-cli/subscription-admin-v1-final/` 和 `.playwright-cli/subscription-admin-v1-sensitive/`,每组包含 425px、900px、1440px 三种视口。 + +## 本地生命周期硬化证据 + +- `plugins/plugin-admin/main_test.go:TestRecoverExternalPluginAfterRestart` 验证外部服务重启后重新探测并保持健康。 +- `plugins/plugin-admin/main_test.go:TestRecoverCommandPluginAfterRestart` 验证托管 command 插件重启后重新分配端口、启动进程组并探测健康/就绪。 +- `plugins/plugin-admin/main_test.go:TestPluginLockSerializesLifecycleMutations` 验证同一插件生命周期互斥。 +- `plugins/plugin-admin/main_test.go:TestIdempotencyKeyRejectsDifferentOperationHash` 和 `TestIdempotencyKeyReplaysSameBodyAndRetainsFailedOperation` 验证服务端请求体指纹、失败终态保留与冲突拒绝。 +- `plugins/plugin-admin/main_test.go:TestUpgradeDoesNotCarryEndpointAcrossServiceModes` 验证 command/external 模式不继承错误端点。 +- `go test -race ./... -count=1`、`go vet ./...`、全部 UI/测试脚本 `node --check`、包构建、`manifestcheck`、`unzip -t` 和 `git diff --check` 已通过。 diff --git a/docs/BUSINESS_PLUGIN_ARCHITECTURE.md b/docs/BUSINESS_PLUGIN_ARCHITECTURE.md new file mode 100644 index 0000000..8842b15 --- /dev/null +++ b/docs/BUSINESS_PLUGIN_ARCHITECTURE.md @@ -0,0 +1,73 @@ +# Business Plugin V1 架构与流程图 + +## 拓扑 + +```mermaid +flowchart TD + B[管理员浏览器] --> C[Core custom_menu_items] + C --> I[Core /custom/:id sandbox iframe] + I --> P[反向代理 /extensions/:plugin-id/] + P --> M[Plugin Control Plane] + M --> S[独立业务插件服务] + S --> A[Typed Core API Adapter] + A --> K[Core Auth/Admin API] + K --> D[Core 权威账本与审计] + M --> R[Plugin Registry / Revisions / Audit] + M --> H[Supervisor + healthz/readyz] +``` + +## 登录时序 + +```mermaid +sequenceDiagram + participant B as Browser + participant P as Plugin BFF + participant C as Core + B->>P: POST /login + P->>C: POST /api/v1/auth/login + C-->>P: access/refresh 或 2FA challenge + P->>C: POST /api/v1/auth/login/2fa (按需) + P->>C: GET /api/v1/auth/me + C-->>P: role=admin + P-->>B: HttpOnly plugin session + CSRF token + B->>P: GET /api/plugins + P->>C: Bearer Core JWT + X-Request-ID + P-->>B: 脱敏业务数据 +``` + +## 生命周期 + +```mermaid +stateDiagram-v2 + [*] --> discovered + discovered --> verified: manifest/signature/hash pass + verified --> installed: atomic staging + installed --> disabled + disabled --> starting: enable + starting --> healthy: health + contract pass + starting --> error: timeout/failure + healthy --> draining: disable/upgrade + draining --> disabled + healthy --> upgrading: new revision + upgrading --> healthy: new revision pass + upgrading --> rollback_pending: new revision fail + rollback_pending --> healthy: old revision restored + verified --> incompatible: version/capability mismatch +``` + +## 数据边界 + +```text +Core authoritative data + user / admin role / balance / plan / subscription / order / usage / billing / audit + ▲ + │ typed HTTPS API, server-side token only + ▼ +Plugin projection and UI + cache / filters / display index / plugin audit reference + ▲ + │ controlled by + ▼ +Plugin control plane + manifest / signature / revision / health / config / menu ownership / session +``` diff --git a/docs/BUSINESS_PLUGIN_BOUNDARIES.md b/docs/BUSINESS_PLUGIN_BOUNDARIES.md new file mode 100644 index 0000000..640645a --- /dev/null +++ b/docs/BUSINESS_PLUGIN_BOUNDARIES.md @@ -0,0 +1,38 @@ +# Business Plugin V1 边界 + +## Core 负责 + +- 用户身份、密码、2FA、TokenVersion、会话撤销和管理员角色; +- 余额、余额流水、套餐、订阅、订单、用量、配额、计费和退款; +- 网关鉴权、请求路由、原子预留/结算、幂等和核心审计; +- Core 数据库 schema、迁移和事务; +- 现有 `.s2plugin` transport ABI 及 OpenAI OAuth 生命周期; +- `custom_menu_items` 的最终校验和页面可见性。 + +## 控制面负责 + +- 业务插件清单、签名、公钥、包哈希和 Core 兼容性; +- 插件安装目录、revision、active 指针和旧版本保留; +- 独立服务进程的启停、drain、健康、升级和回滚; +- 插件配置加密、会话、CSRF、权限和控制面审计; +- 由插件声明的管理员菜单 preview/apply; +- 只允许服务端调用的 Core API Adapter。 + +## 业务插件负责 + +- 自己的业务 UI、HTTP API、BFF 和领域逻辑; +- 自己声明的 Core API allowlist、字段映射、分页和错误展示; +- 可删除、可重建的只读 projection; +- 自己的健康端点、版本报告和配置校验; +- 不影响 Core 的独立发布和回滚。 + +## 明确禁止 + +- 插件前端持有 Core JWT、refresh token、Admin Key 或服务 secret; +- 插件直连 Core PostgreSQL、Redis、宿主文件目录或内部 Go 包; +- 插件自行判断余额、权限、配额、计费或网关放行; +- 用多个 Admin API 拼接一个本应由 Core 原子事务完成的购买/扣款/续费; +- 把业务插件声明成 `openai.oauth.outbound_transport.v1`; +- 通过 iframe URL、LocalStorage、查询参数或日志传递认证凭据; +- 由插件卸载流程删除 Core 账本、订阅或审计数据; +- 在 V1 承诺无感 SSO、远程任意下载、OS 沙箱或跨插件 RPC。 diff --git a/docs/BUSINESS_PLUGIN_DEVELOPMENT.md b/docs/BUSINESS_PLUGIN_DEVELOPMENT.md new file mode 100644 index 0000000..a6e3b6b --- /dev/null +++ b/docs/BUSINESS_PLUGIN_DEVELOPMENT.md @@ -0,0 +1,46 @@ +# Business Plugin V1 开发指南 + +## 目录模板 + +```text +my-business-plugin/ +├── cmd/plugin/main.go +├── internal/auth/ +├── internal/coreadapter/ +├── internal/manifest/ +├── internal/domain/ +├── ui/index.html +├── ui/assets/ +├── business-plugin-manifest.v1.json +├── deploy/ +├── test/contract/ +└── README.md +``` + +入口服务只负责加载配置、启动 HTTP server 和健康端点。鉴权、Core API、领域逻辑、manifest 校验和 UI 不要全部写在入口文件。 + +## 开发顺序 + +1. 先复制 manifest 模板,声明唯一 `plugin_id`、capability、Core baseline、服务健康路径、UI 入口和精确 allowlist。 +2. 实现 Core Adapter 的 typed methods;禁止接受浏览器传入的任意 URL。 +3. 实现管理员登录、2FA、HttpOnly session、CSRF、refresh budget、登出撤销和日志脱敏。 +4. 实现领域 API 和 UI;浏览器只调用插件 BFF,不读取 Core token 或宿主存储。 +5. 提供 `healthz`、`readyz`、版本和 request ID;失败时保持 fail-closed。 +6. 由控制面执行签名、哈希、兼容性、启停、升级和回滚;插件服务不自行覆盖其他插件资源。 +7. 用 deployment-managed `custom_menu_items` preview/apply 注入管理员入口。 + +## UI 约束 + +业务插件页面遵循 `docs/FRONTEND_DESIGN_GUIDELINES.md` 和 `UI.MD` 的控制高度、无衬线数字、响应式表格、无卡片嵌套和安全错误展示要求。iframe 与新窗口都必须可用;独立 origin 按部署配置 Cookie `SameSite=None; Secure`,同源反代优先使用 `Lax`。 + +## 测试最小集 + +- manifest 未知字段、尾随数据、路径穿越、签名和哈希; +- 管理员、普通用户、2FA、撤销、刷新、登出和 CSRF; +- Core API allowlist、查询过滤、401 单次 refresh、429/5xx 策略; +- secret 不出浏览器/日志/错误; +- health/readiness、启停、drain、升级、回滚和卸载; +- iframe、新窗口、刷新恢复、425/900/1440px 截图和横向溢出; +- 清空插件投影后从 Core 重建。 + +订阅插件的套餐、余额、订阅实例、同档多实例、单独续费和余额事务规则只写在订阅领域文档,不复制到本通用指南。 diff --git a/docs/BUSINESS_PLUGIN_FRAMEWORK_V1.md b/docs/BUSINESS_PLUGIN_FRAMEWORK_V1.md new file mode 100644 index 0000000..e76c3d5 --- /dev/null +++ b/docs/BUSINESS_PLUGIN_FRAMEWORK_V1.md @@ -0,0 +1,212 @@ +# Sub2API 独立业务插件框架 V1 + +状态:Accepted Contract / V1 参考实现已完成本地验收 + +本文定义与 Sub2API Core 解耦的通用业务插件框架。业务插件是独立服务、独立端口、独立版本和独立 UI;它可以通过现有管理员自定义菜单嵌入 Core,也可以在新窗口运行。订阅管理只是一个可选业务插件,不能成为框架后台、Core 热路径或插件生命周期的固定组成部分。 + +## 1. 目标 + +V1 需要提供一个独立的插件控制面,负责: + +- 展示已登记、已安装和可升级的业务插件; +- 校验包清单、发布者签名、文件哈希和 Core 兼容范围; +- 安装、启用、停用、健康检查、升级、回滚、卸载和配置插件; +- 保存插件版本、服务地址、运行状态、菜单声明和操作审计; +- 通过 Core 现有管理员鉴权复用操作者身份; +- 将已启用插件的管理员菜单注入 `custom_menu_items`; +- 让每个业务插件仅通过自己的 BFF 调用 Core 明确允许的 API。 + +V1 不改变 Core Go/Vue、数据库迁移、现有鉴权、前端路由或 `.s2plugin` transport ABI。控制面自己的注册表、安装目录、进程和配置存储属于独立服务;它不连接 Core PostgreSQL、Redis 或宿主业务表。 + +## 2. 与现有插件的关系 + +| 类型 | 现有 `.s2plugin` transport | Business Plugin V1 | +|---|---|---| +| 运行方式 | Core 子进程 + gRPC | 独立服务 + HTTP/BFF | +| 能力 | `openai.oauth.outbound_transport.v1` | 由清单声明的业务能力 | +| 生命周期 | Core `PluginManager` | 独立 Plugin Control Plane | +| UI | 配置 iframe + UI Bridge | 业务后台/用户工具页面 | +| 数据边界 | Core 负责账号转发与计费 | Core 负责权威业务数据,插件只读/投影 | +| 菜单 | Core 固定插件管理页 | `custom_menu_items` 管理员入口 | + +现有 `.s2plugin` 的 `TransportPlugin`、清单 schema 和 capability 校验保持不变。业务插件不能仅通过声明一个新 capability 假装获得 HTTP 路由、数据库、支付或订阅权限。 + +## 3. V1 拓扑 + +```text +管理员浏览器 + │ Core 登录后的管理员菜单 + ▼ +Core /custom/ + │ sandbox iframe 或新窗口 + ▼ +反向代理 /extensions// + ▼ +Business Plugin Control Plane + ├─ 插件目录、签名、版本和状态 + ├─ 独立进程 supervisor + ├─ 管理员会话和审计 + └─ 插件 BFF / Core API Adapter + │ 仅发送服务端 Bearer Core JWT + X-Request-ID + ▼ +Sub2API Core 现有鉴权、Admin API 和领域账本 +``` + +控制面可以托管插件进程,也可以把进程交给 systemd、容器或 Kubernetes;无论采用哪种 supervisor,控制面都必须能读取健康状态并保留旧版本回滚点。停用时先撤销自有菜单,再将托管进程置于有界终止流程(SIGTERM,最多等待 10 秒后强制结束);反向代理负责停止新请求,外部服务只做健康探测并由其部署者负责停机。 + +## 4. 角色和权限 + +- `plugin_admin`:安装、启停、升级、回滚、卸载、配置和菜单注入。 +- `plugin_operator`:查看状态、日志摘要和健康诊断,不改变包或凭据。 +- `plugin_readonly`:只读查看已登记插件。 + +V1 的控制面只允许 Core `role=admin` 登录。插件不创建第二套 Core 用户表;插件会话只保存 `plugin_id`、`admin_user_id`、角色、会话版本和过期时间。UI 隐藏按钮不等于授权,控制面和 Core API 均须重新校验权限。 + +## 5. 管理面契约 + +控制面内部 API 的最小集合: + +```text +GET /healthz +POST /login +POST /login/2fa +POST /logout +GET /api/me +GET /api/plugins +GET /api/plugins/{id} +POST /api/plugins/{id}/install +POST /api/plugins/{id}/enable +POST /api/plugins/{id}/disable +POST /api/plugins/{id}/upgrade +POST /api/plugins/{id}/rollback +POST /api/plugins/{id}/uninstall +GET /api/plugins/{id}/config +PUT /api/plugins/{id}/config +GET /api/audit +POST /api/menu-items/preview +POST /api/menu-items/apply +``` + +所有写请求要求 CSRF、操作者会话和幂等键。幂等指纹由服务端根据方法、路径、查询和请求体计算,失败操作也保留终态,重复请求不会重新执行。安装、启用、升级、回滚和卸载必须返回 operation ID,并可通过插件详情查询最终状态。控制面不把上述路径注册到 Core,也不声称 Core 已存在 `/api/v1/plugin-host/*`。 + +## 6. 插件生命周期 + +```text +discovered -> verified -> installed -> disabled -> starting -> healthy + │ │ + │ └── error + └── incompatible + +healthy -> draining -> disabled +healthy -> upgrading -> healthy +healthy -> rollback_pending -> healthy +``` + +- `discovered`:目录或清单被发现,尚未验签。 +- `verified`:清单、签名、哈希和兼容性通过。 +- `installed`:版本包已安全写入 staging 并原子切换。 +- `disabled`:已安装但不接收业务请求,菜单默认隐藏。 +- `starting`:进程启动、端口和健康检查进行中。 +- `healthy`:健康端点、就绪端点和(若响应提供)版本检查通过。 +- `draining`:菜单已撤销,托管进程正在执行有界终止;在途请求由插件进程或反向代理按部署约定处理。 +- `upgrading` / `rollback_pending`:新旧版本并存检查,只有健康版本成为 active。 +- `error`:进程、健康、配置或 Core 合约失败,默认 fail-closed。 +- `incompatible`:当前 Core baseline 或协议不满足清单要求。 + +已启用版本不能被原地覆盖。升级先安装新 revision、执行健康和契约检查,再原子更新 active revision;至少保留一个可回滚 revision。卸载只能作用于停用插件,不删除 Core 数据。控制面重启时重新校验 `healthy`/`enabled` 插件:托管 command 重新启动 active revision,外部服务重新探测 `service_url`;探测失败统一标记 `error` 并清空不可用端点。`backend.command` 与外部 `service_url` 是互斥运行模式,升级不会继承不属于新模式的旧端点。 + +## 7. 安装和包安全 + +安装包必须包含清单和 UI;若插件由控制面托管进程,再额外包含清单声明的服务文件: + +```text +manifest.json +signature.json +ui/index.html +ui/assets/... +``` + +外部服务模式允许省略 `service/` 文件,但清单不得声明 +`backend.command`,且启用前必须在控制面配置经过校验的 `service_url`。托管进程模式 +必须声明 `backend.command`,并将该路径及二进制哈希放入包内。 + +控制面必须拒绝绝对路径、父目录跳转、重复条目、符号链接、未声明文件、超大文件和不匹配哈希。签名使用 Ed25519,签名覆盖 `manifest.json` 原始字节;清单中的 SHA-256 覆盖服务文件和 UI。发布者私钥不进入仓库、包、服务器或日志。 + +安装使用临时目录和原子 rename;失败不得破坏 active revision。包来源默认是管理员上传或受控本地目录,V1 不自动从互联网下载任意包。 + +## 8. Core API Adapter + +每个插件清单声明精确的 `method + path` allowlist。控制面或插件 BFF 只能调用这些路径: + +```http +POST /api/v1/auth/login +POST /api/v1/auth/login/2fa +POST /api/v1/auth/refresh +POST /api/v1/auth/logout +GET /api/v1/auth/me +GET /api/v1/settings/public +GET /api/v1/admin/ +``` + +`` 只是文档占位符,实际清单必须列出具体路径、查询参数、分页上限和响应字段。浏览器永远不接触 Core JWT、refresh token 或 Admin Key;所有出站请求由服务端添加 `Authorization: Bearer ...` 和统一 `X-Request-ID`。 + +Core 返回 `401` 时,一次用户请求最多 refresh 一次;并发 refresh 必须按插件会话串行化。`403` 不重试,`429` 按 `Retry-After` 有上限退避,`5xx` 只重试明确幂等操作。响应按 DTO 或敏感键规则脱敏,不能把 token、密码、Cookie、secret 或完整凭据透传给 UI。 + +## 9. 会话和嵌入 + +插件登录调用 Core 现有 `/auth/login`、按需 `/auth/login/2fa`,再调用 `/auth/me` 校验管理员角色。Core token 只存插件服务端会话,浏览器只持有 HttpOnly、Secure、SameSite Cookie 和插件 CSRF token。 + +Core 的自定义页面当前使用 sandbox iframe,且不会自动继承 Core `localStorage` 登录态。因此 V1 必须同时提供新窗口入口;iframe 首屏显示插件登录页是已知行为。真正无感 SSO 需要 V1.1 的一次性 code/state 或受控 `postMessage` 交接,不得把 JWT 放在 URL。 + +菜单注入使用 Core 现有 `custom_menu_items`: + +```json +{ + "id": "DOMAIN_PLUGIN_ID", + "label": "DOMAIN_PLUGIN_LABEL", + "url": "https://CORE_ORIGIN/extensions/DOMAIN_PLUGIN_ID/", + "visibility": "admin", + "sort_order": 200 +} +``` + +控制面只能创建和更新自己声明的 ID,保留其他管理员菜单;应用前展示 diff 并记录审计。停用或卸载时先隐藏/移除自己拥有的菜单项,再停止进程。 + +## 10. 数据归属 + +Core 始终是用户身份、余额、订阅、订单、用量、权限、计费和审计的权威来源。控制面只保存插件包、revision、状态、服务配置、菜单声明、会话和操作索引。业务插件可以保存可删除、可重建的 projection,但 projection 不得作为 Core 网关放行、扣费或权限判断依据。 + +## 11. 安全和可运维性 + +- 监听地址默认 loopback,生产通过 HTTPS 反向代理暴露。 +- 插件进程使用独立低权限账号/容器、最小文件权限和出站网络 allowlist。 +- 配置 secret 使用服务端加密存储或 secret manager,UI 只显示 configured/rotatable,不回显原值。 +- 日志只记录插件 ID、revision、操作者、operation ID、资源 ID、request ID、状态和耗时。 +- 进程崩溃、健康失败、Core 不兼容或签名错误均 fail-closed,不静默切换到未验证版本。 +- 停用、升级、回滚和卸载必须可重复执行;Core 数据不随插件卸载删除。 + +## 12. 分阶段实施 + +### Phase 0:契约冻结 + +冻结 manifest、签名、revision、控制面 API、状态机、反代路径、菜单字段、会话属性和 Core API allowlist。 + +### Phase 1:通用控制面 + +实现管理员登录、插件目录、包校验、安装/启停、健康检查、配置加密、审计、菜单 preview/apply、systemd/container 适配和回滚骨架。 + +### Phase 2:业务插件适配 + +提供 `DOMAIN_PLUGIN_ID` 级别的 SDK/模板和契约测试。订阅管理作为首个独立业务插件接入,只实现自身领域页面和 Core 只读 API,不改变控制面。 + +### Phase 3:生产增强 + +评审多实例共享状态、短时 Plugin Access Token、无感 SSO、Core Host Adapter、远程 registry、灰度升级和跨节点 drain;这些能力需要单独版本和安全评审。 + +## 13. 非目标 + +- 不把业务插件注册为现有 OpenAI OAuth transport。 +- 不把任意业务路由、数据库、支付、余额扣款或每请求计费放进控制面。 +- 不创建 Core 用户表、插件版账本或绕过 Core 鉴权。 +- 不通过 URL、iframe、LocalStorage、HTML、日志或下载文件传递凭据。 +- 不承诺独立进程是操作系统级沙箱。 diff --git a/docs/BUSINESS_PLUGIN_MANIFEST_V1.md b/docs/BUSINESS_PLUGIN_MANIFEST_V1.md new file mode 100644 index 0000000..5498ccf --- /dev/null +++ b/docs/BUSINESS_PLUGIN_MANIFEST_V1.md @@ -0,0 +1,78 @@ +# Business Plugin Manifest V1 + +状态:Accepted Contract + +业务插件清单由独立控制面读取和校验,Core 当前不会读取它。清单只声明插件身份、版本、能力、服务、UI、兼容性、发布者和 Core API 权限,不授予数据库、路由或 secret 权限。 + +## 1. 最小清单 + +```json +{ + "schema_version": 1, + "plugin_id": "DOMAIN_PLUGIN_ID", + "name": "DOMAIN_PLUGIN_NAME", + "version": "1.0.0", + "core_api_baseline": "sub2api-0.1.183", + "tested_core_versions": ["0.1.183"], + "capabilities": ["DOMAIN_CAPABILITY_V1"], + "backend": { + "listen_env": "PLUGIN_PORT", + "health_path": "/healthz", + "readiness_path": "/readyz" + }, + "ui": { + "entrypoint": "/admin/", + "menu": { + "id": "DOMAIN_PLUGIN_ID", + "label": "DOMAIN_PLUGIN_LABEL", + "visibility": "admin", + "sort_order": 200 + } + }, + "publisher": { + "key_id": "PUBLISHER_KEY_ID" + }, + "core_api_allowlist": [ + "POST /api/v1/auth/login", + "POST /api/v1/auth/login/2fa", + "POST /api/v1/auth/refresh", + "POST /api/v1/auth/logout", + "GET /api/v1/auth/me", + "GET /api/v1/settings/public", + "GET /api/v1/admin/DOMAIN_READ_ENDPOINT" + ] +} +``` + +## 2. 字段规则 + +- `plugin_id`:小写、稳定、全局唯一;不得包含 `/`、空格或路径跳转。 +- `version`:插件自身 SemVer,与 Core 版本和发行标签分离。 +- `core_api_baseline`:插件编译和契约测试所针对的 Core 版本。 +- `tested_core_versions`:只填写真实执行过契约测试的版本。 +- `capabilities`:一个或多个业务能力 ID;控制面不为未实现的能力自动创建路由。 +- `backend.health_path`、`readiness_path`:只能是插件服务根下的绝对 HTTP 路径(例如 `/healthz`、`/readyz`),不得包含主机、查询、片段或路径跳转。 +- `ui.entrypoint`:只能指向包内 UI 资源。 +- `ui.menu`:管理员菜单声明;控制面必须校验 ID 与插件 ID 绑定,`visibility` 只能是 `admin`。 +- `publisher.key_id`:必须匹配受信任发布者配置。 +- `core_api_allowlist`:方法和路径必须逐项列出,不允许通配符、任意 URL 或未声明查询参数。 + +## 3. 签名和哈希 + +```json +{ + "algorithm": "ed25519", + "key_id": "PUBLISHER_KEY_ID", + "signature": "BASE64_SIGNATURE" +} +``` + +签名覆盖 `manifest.json` 原始字节。包内每个服务文件和 UI 文件的 SHA-256 由清单声明。验证器必须拒绝尾随 JSON、重复字段、未知字段、无效 Base64、大小写错误的哈希和不匹配的 key ID。 + +## 4. 兼容性门禁 + +1. 清单 schema 版本不匹配:`incompatible`。 +2. Core 不在 `requires` 范围:`incompatible`。 +3. Core 在范围内但未列入 `tested_core_versions`:安装后保持 disabled,要求管理员确认。 +4. 业务能力、服务协议或 UI 版本不支持:禁止启用。 +5. 升级只产生新 revision;旧 active revision 在新版本健康和契约测试通过前保持可用。 diff --git a/docs/BUSINESS_PLUGIN_V1_IMPLEMENTATION.md b/docs/BUSINESS_PLUGIN_V1_IMPLEMENTATION.md new file mode 100644 index 0000000..5f519d4 --- /dev/null +++ b/docs/BUSINESS_PLUGIN_V1_IMPLEMENTATION.md @@ -0,0 +1,73 @@ +# Business Plugin V1 实现边界与验收 + +状态:控制面入口已实现;业务插件按包独立接入 + +## 1. 唯一入口 + +`plugins/plugin-admin` 是通用 Business Plugin V1 控制面。它的首页和默认菜单只能表达“插件管理”,不表达任何具体业务域,也不默认打开订阅、支付或其他业务页面。 + +控制面负责: + +- 展示已登记、已安装和可升级的插件包; +- 校验清单、签名、文件哈希和 Core 兼容性; +- 安装、启用、停用、升级、回滚、卸载和配置; +- 显示运行状态、健康检查结果和操作审计; +- 对插件声明的管理员菜单执行预览和应用。 + +控制面不负责: + +- 订阅商品、余额、订单、支付、配额或请求计费; +- 任何业务插件自己的页面和领域数据; +- Core 数据库、Core 用户表或 `.s2plugin` transport ABI。 + +## 2. 安装后注入流程 + +```text +管理员登录 plugin-admin + | + v +插件目录 -> 上传/选择业务插件包 + | + v +清单 + 签名 + 哈希 + Core 兼容性校验 + | + v +安装到独立 revision,初始为 disabled + | + v +启用 -> 启动独立服务端口 -> healthz/readyz/版本检查 + | + v +菜单预览 -> 管理员确认 -> 应用 custom_menu_items + | + v +Core 管理员菜单出现该插件自己的入口 +``` + +每个插件使用自己的 `plugin_id`、版本、端口、服务进程、UI 和菜单 ID。停用或卸载插件时,只移除该插件自己声明的菜单项,不触碰其他插件或 Core 数据。 + +## 3. 订阅插件的位置 + +`plugins/subscription-admin` 是第一个业务插件样例,而不是控制面。它只有在管理员通过 `plugin-admin` 安装、启用并应用菜单后才出现。卸载订阅插件只删除插件资源和自身投影,不删除 Core 的套餐、订阅、余额、订单、用量或审计。 + +订阅插件的 V1 只读取 Core 现有管理员 API;余额购买、续费、撤销和退款写操作必须等待版本化 Core 原子接口,不得把多个 Admin API 拼成一次购买。 + +## 4. 登录与安全边界 + +- 控制面和业务插件均复用 Core 管理员登录及 2FA,不创建插件用户表;普通 Core 用户统一拒绝。 +- 浏览器只持有插件自己的 HttpOnly 会话和 CSRF token;Core access/refresh token、Admin Key 只存在插件服务端。 +- 插件后端通过精确 allowlist 调用 Core API,不提供任意 URL 代理,不连接 Core PostgreSQL/Redis。 +- 插件包必须签名;未签名包仅限开发环境 loopback 测试。 + +## 5. 功能验收最小条件 + +1. 打开 `plugin-admin` 首屏看到插件目录,而不是订阅页面。 +2. 未安装订阅包时,目录可以为空,左侧不会出现订阅菜单。 +3. 安装并启用一个包后,详情页显示该包的状态、版本和健康结果。 +4. 菜单预览只新增该包自己的菜单项;应用后 Core 管理员菜单才出现该入口。 +5. 停用或卸载后入口消失,其他菜单保持不变。 +6. 425px、900px、1440px 三种视口均无横向溢出、遮挡或凭据泄漏。 + +## 6. 后续插件模板 + +新增业务插件只需提供独立清单、服务、UI、Core API allowlist 和菜单声明,并遵守本文件的安装生命周期。插件管理控制面不因新增业务域而增加订阅、支付或其他领域分支。 diff --git a/docs/PLUGIN_FRAMEWORK_V1_RFC.md b/docs/PLUGIN_FRAMEWORK_V1_RFC.md new file mode 100644 index 0000000..5547502 --- /dev/null +++ b/docs/PLUGIN_FRAMEWORK_V1_RFC.md @@ -0,0 +1,393 @@ +# Sub2API 独立业务插件框架 V1 RFC + +状态:V1 通用插件控制面参考实现已落库;订阅业务插件只读适配与 Core Host Adapter/写操作仍为 Draft + +本文规划一种不改动 Sub2API 核心代码、数据库和现有插件 ABI 的独立业务插件框架。当前 V1 控制面参考实现位于 `plugins/plugin-admin`,它负责插件清单、签名、安装、启停、升级、回滚、卸载、配置、审计和菜单注入;控制面本身不是订阅后台。每个业务插件(包括独立的 `plugins/subscription-admin`)作为可选的独立服务运行在自己的端口,通过控制面安装后再由部署层反向代理和现有“管理员可见自定义菜单”嵌入 Sub2API 页面。插件登录直接调用 Core 的现有鉴权,普通账号没有访问权限,也不复制 Core 用户表。 + +V1 已实现范围以 `plugins/plugin-admin` 控制面和本文“当前实现范围”章节为准;本文中的订阅业务插件只是首个适配样例。Core Host Adapter、短时 Plugin Access Token、无感 SSO 和余额写操作仍是后续版本设计,不代表当前 Core 已提供这些接口。 + +本文不是现有 `.s2plugin` 协议的直接改版。现有 `.s2plugin` 继续只负责 `openai.oauth.outbound_transport.v1`。本 RFC 的 V1 是部署级业务插件约定,不向当前 Core 增加新的路由、数据表或业务 RPC。 + +## 0. 约束与可行性边界 + +本版本必须同时满足以下约束: + +- 不修改 Sub2API 的 Go、Vue、数据库迁移和现有鉴权实现; +- 不在插件内建立 Core 用户表,管理员身份以 Core 现有账号和角色为准; +- 插件后端独立监听 `PLUGIN_PORT`,由 Nginx/Caddy/Traefik 等部署层转发; +- 通过 Core 已有 `custom_menu_items` 配置添加 `visibility=admin` 的 iframe 菜单入口; +- 插件只在服务端调用 Core 现有 API,浏览器不持有 `x-api-key` 或 Core JWT; +- 第一阶段只搭框架、登录、权限、健康检查、嵌入和只读联调,不实现订阅购买写操作。 + +现有自定义菜单可以完成“把插件页面显示在 Sub2API 管理页面内”,但现有 iframe 使用 sandbox,且 Core 前端 JWT 保存在 `localStorage`,不会自动注入跨端口 iframe。因此在完全不改 Core 的前提下,V1 的登录方式是:插件登录页把凭据转交给插件后端,插件后端调用 Core 现有登录和二次验证接口,确认 `role=admin` 后只保留短时插件会话及服务端 Core token;这复用同一套 Core 用户和角色,不复制用户表,但不是无感知的当前页面会话共享。 + +如果以后要求“已登录 Core 后打开 iframe 立即无感登录”,需要一个很小的 Core 一次性登录交接接口或前端 `postMessage` 适配;这属于 V1.1,不应通过 URL 明文传递 JWT。反向代理只改变网络路径,不改变这一认证边界。 + +## 1. 决策摘要 + +### 1.1 推荐拓扑 + +```text +管理员浏览器 + │ Core 页面中的 admin 自定义菜单 + ▼ +Core `/custom/PLUGIN_ID` + │ 现有 sandbox iframe;只加载插件 UI,不共享 Core 存储 + ▼ +反向代理 `/extensions/PLUGIN_ID/*` + │ 转发到独立服务 `127.0.0.1:PLUGIN_PORT` + ▼ +业务插件后台 + ├─ `/login` 接收管理员登录请求 + ├─ 服务端调用 Core `/api/v1/auth/login`(需要时调用 `/login/2fa`) + ├─ 调用 Core `/api/v1/auth/me`,确认 `role=admin` + ├─ 建立插件 HttpOnly 会话,Core access/refresh token 只在服务端保存 + └─ 通过 Bearer Core JWT 调用现有 Core Admin API + ▼ +Sub2API Core 现有认证、Admin API 和订阅/余额账本 +``` + +插件后台是独立服务,不以高权限子进程形式嵌入核心,也不直接连接核心数据库。V1 通过部署层完成端口映射,通过 Core 现有登录/2FA、`/auth/me` 和 Admin API 完成功能联调;浏览器永远不接触 Admin Key,也不接触 Core JWT。插件后端不建立用户表,只维护短期会话(单实例可使用内存,多实例可使用插件自己的 Redis/会话存储)。这里的前提是插件属于受信任的内部服务:登录密码会在一次请求中经过插件后端再转交 Core,但不落库、不写日志;若要求插件进程也完全接触不到密码,需要另行引入 Core SSO/OIDC。 + +如果部署环境只允许服务到服务调用,也可以把 Core Admin API Key 放在插件后端 secret 中作为临时 BFF 凭据;这时所有命令都以该 Key 对应的管理员身份执行,属于单一服务身份模式,不等同于当前浏览器管理员的 SSO,也不替代 V1 的管理员登录方案。 + +### 1.2 V1 的默认范围 + +- 插件拥有独立后台和独立发布版本。 +- 只允许管理员登录;普通用户访问插件后台一律拒绝。 +- 首版支持管理员登录、权限校验、健康检查、嵌入和只读订阅数据展示。 +- 余额购买命令暂不实现,待框架验收后再复用现有订阅逻辑设计原子入口。 +- 现有 `.s2plugin` 传输插件 ABI、路由和生命周期保持不变。 +- 不实现插件任意注册核心路由、任意执行 SQL、任意读取宿主 Cookie、任意注入 Vue 路由或任意修改 Core 菜单组件。 + +## 2. 为什么不复用现有 `.s2plugin` + +现有插件协议的能力是 `GetInfo`、`Health`、配置读写、配置测试和 `Forward` HTTP 流。它的清单只接受 `openai.oauth.outbound_transport.v1`,UI 也只是无管理员 Token 的配置 iframe。 + +因此它不适合安全承载以下业务: + +- 管理员登录和角色授权; +- 订阅商品、余额购买和订单审计; +- 核心数据库事务或迁移; +- 用户页面、菜单、任意 HTTP 路由和支付/订阅回调。 + +如果把这些能力硬塞入现有协议,就会同时改变进程 ABI、权限模型、数据库边界和前端路由,反而扩大与上游的冲突面。V1 采用“外部业务插件 + Core 现有 API 适配器”作为清晰的新边界;需要 Core 新增接口的部分另列为 V1.1。 + +## 3. 术语和边界 + +| 术语 | 定义 | +|---|---| +| Core | Sub2API 主服务,拥有用户、余额、Group、Key、订阅和用量账本。 | +| Business Plugin | 独立部署的后台服务,提供一个业务域的管理 UI 和 BFF。 | +| Plugin UI | 由 Business Plugin 提供的页面,只调用自己的后端。 | +| Plugin Backend | Business Plugin 的服务端,保存插件配置、会话和操作幂等记录。 | +| Core API Adapter | V1 插件后端对 Core 现有 REST API 的服务端客户端,只允许访问明确的认证、管理员和只读业务端点。 | +| Future Host Adapter | V1.1 以后、需要修改 Core 才能提供的版本化业务 API;不属于本次无 Core 改动的实现范围。 | +| Plugin Session Credential | 插件自己的 HttpOnly 会话标识,不等于 Core JWT 或全局 Admin Key。 | +| Capability | 插件声明的业务能力,例如 `subscription.admin.v1`。 | +| Projection | 插件保存的只读或可重建副本,不是核心账本的权威数据。 | + +## 4. 认证与权限模型 + +### 4.1 管理员登录 + +V1 采用“插件会话 + Core 现有登录”的两层模型,不创建插件用户表: + +1. 浏览器打开插件 `/login`,只向插件后端提交 Core 管理员凭据和必要的 2FA 信息。 +2. 插件后端服务端调用 Core `POST /api/v1/auth/login`;需要二次验证时继续调用 `POST /api/v1/auth/login/2fa`。 +3. 插件后端用返回的 Core access token 调用 `GET /api/v1/auth/me`,确认用户状态正常且 `role=admin`。 +4. 插件后端建立自己的短时 HttpOnly 会话;Core access/refresh token 只保存在插件服务端的会话存储中,不回传浏览器。 +5. 插件业务请求只携带插件会话 Cookie,插件后端再用对应管理员的 Core Bearer token 调用允许的 Core API。 + +```text +浏览器 -> Plugin /login(插件会话 Cookie 尚未建立) +Plugin -> Core /api/v1/auth/login +Plugin -> Core /api/v1/auth/login/2fa(按 Core 返回的要求) +Plugin -> Core /api/v1/auth/me(确认 role=admin) +Plugin <- 建立 HttpOnly 插件会话 +浏览器 -> Plugin /admin/*(只带插件会话 Cookie) +Plugin -> Core /api/v1/admin/*(只在服务端带 Bearer Core JWT) +``` + +这不是独立账号,也不是把 Core 用户复制到插件;密码只用于一次 Core 登录请求,插件不落库。插件会话可使用内存存储;多实例部署时使用插件自己的 Redis/会话存储,不连接 Core 数据库。Core 继续负责密码、2FA、限流、TokenVersion、撤销和管理员角色校验。 + +当前 Core 没有给自定义 iframe 提供 token handoff,因此“Core 已登录后打开 iframe 自动登录”不属于 V1。V1 允许在 iframe 内显示插件登录页;由于 sandbox/第三方 Cookie 策略可能让嵌入会话在刷新后失效,插件必须提供“新窗口打开插件”入口作为稳定登录路径。以后如需真正无感 SSO,另行设计一次性 code + state 或 `postMessage` 交接机制,JWT 不应放在 URL。 + +### 4.2 权限判定 + +- 默认拒绝所有普通用户、未登录用户和过期会话。 +- 插件会话只包含 `plugin_id`、`admin_user_id`、角色、权限版本、签发时间和过期时间。 +- V1 只定义 `plugin_admin` 角色;后续可增加 `subscription_operator`、`subscription_readonly`。 +- 插件后端每次命令都携带对应 Core 管理员的 Bearer token,不使用“系统管理员”固定身份代替实际操作者(仅使用临时 Admin Key 的过渡模式除外)。 +- Core 对每个现有 Admin API 操作再次做管理员角色、会话撤销和资源级授权,不把插件 UI 的按钮隐藏当作授权依据。 +- 所有写操作要求 CSRF 防护、审计记录和幂等键;敏感操作可要求 step-up。 + +### 4.3 会话要求 + +- 会话 Cookie 必须 `HttpOnly`、`Secure`、`SameSite=Lax` 或更严格。 +- 生产环境只允许 HTTPS;反向代理必须正确传递原始 Host 和协议。 +- 登录使用短时一次性 state,绑定浏览器会话并防重放。 +- 默认会话有效期 30 分钟,滑动续期上限 8 小时;登出立即撤销服务端会话。 +- 独立 origin 嵌入时按浏览器策略使用 `SameSite=None; Secure`,同源反代优先使用 `Lax`;必须在目标浏览器验证刷新、退出和第三方 Cookie 行为。 +- 插件 UI 不把 Core JWT、Admin Key 或服务凭据写入 LocalStorage、URL、HTML、日志或错误提示。 + +## 5. Admin Key 与服务凭据安全 + +### 5.1 绝对禁止的做法 + +- 不把全局 `x-api-key` 注入浏览器。 +- 不把 Admin Key 放进插件静态 JS、HTML、iframe、URL query、下载文件或前端 sourcemap。 +- 不让插件 UI 直接请求 Core Admin API。 +- 不把 Admin Key 写入普通业务日志、请求追踪、错误响应、数据库明文或备份导出。 +- 不让插件直接连接 Core PostgreSQL、Redis 或宿主文件目录。 + +### 5.2 V1 凭据分级 + +| 环境 | 凭据 | 用途 | 约束 | +|---|---|---|---| +| 开发 | Core 管理员的临时登录凭据 | 调用 Core `/auth/login` 联调 | 只由开发者输入到插件登录页,不写入代码、配置和日志。 | +| 测试 | Core 返回的 access/refresh token | 建立插件服务端会话 | 只存插件服务端会话存储,短 TTL,测试 Core 与测试管理员专用。 | +| 生产 | Core 返回的 access/refresh token | 代表实际登录的 Core 管理员调用现有 Admin API | 服务端加密保存或内存保存,按 Core TokenVersion/撤销结果失效;不回传浏览器。 | + +V1 不要求新增 Plugin Access Token 服务,也不要求修改 Core。`ADMIN_API_KEY` 只作为服务到服务 BFF 的应急/测试凭据:它必须只存在插件后端 secret manager、受限环境变量或权限为 `0600` 的 secret 文件中,并由 Core Admin API 的 endpoint allowlist 限制。使用 Admin Key 时所有请求都以同一个管理员身份执行,审计粒度是服务身份级别,不得作为插件登录态下发给浏览器。 + +V1.1 如需在不保存 Core refresh token 的情况下运行,再设计 Core 签发的短时、限 scope Plugin Access Token;在 Core 提供该能力前,文档只把它视为未来接口。 + +### 5.3 凭据生命周期 + +1. 插件后端接收管理员登录请求,但不保存密码。 +2. 插件后端调用 Core 登录/2FA,保存返回 token 到服务端会话,并绑定 `admin_user_id`。 +3. 每次 Core 请求都使用 TLS 和 Core Bearer token;Core 继续校验签名、TokenVersion、会话绑定和角色。 +4. access token 过期时只使用对应 refresh token 调用 Core `/auth/refresh`;刷新失败就销毁插件会话并要求重新登录。 +5. 登出、Core 管理员撤销会话、停用插件或发现泄露时,立即删除插件会话;Admin Key 过渡模式由运维轮换并撤销。 + +### 5.4 服务端防护 + +- Core 现有 Admin API 由自身 `adminAuth`、JWT 会话和管理员角色保护;插件后端再使用出站 allowlist,只能访问事先批准的 Core API 路径。 +- 插件后端不接受任意 URL 转发,也不把 Core API 代理能力暴露给插件 UI。 +- 请求设置超时、重试上限和熔断;禁止在超时后盲目重放非幂等命令。 +- 日志对 `Authorization`、`x-api-key`、Cookie、session、余额和个人信息做结构化脱敏。 +- 记录 `plugin_id`、操作者、scope、资源 ID、幂等键和结果,不记录 secret 原文。 +- 生产插件运行在独立低权限账号或容器中,限制文件、网络、系统调用和环境变量。 + +## 6. Core API Adapter V1(使用现有接口) + +本节描述在“不修改 Sub2API”前提下插件可以使用的最小集成面。它不是 Core 已注册的插件协议,而是插件后端对现有 HTTP API 的严格 allowlist;实现前应根据目标版本在插件配置中冻结路径和响应字段。 + +### 6.1 认证头 + +```http +Authorization: Bearer CORE_ACCESS_TOKEN +X-Request-Id: UNIQUE_REQUEST_ID +``` + +`CORE_ACCESS_TOKEN` 只允许由插件后端的会话适配器发送。插件浏览器、静态资源和 iframe 消息中不得出现该 token。 + +### 6.2 V1 允许的接口类别 + +```text +POST /api/v1/auth/login +POST /api/v1/auth/login/2fa +POST /api/v1/auth/refresh +POST /api/v1/auth/logout +GET /api/v1/auth/me +GET /api/v1/settings/public +GET /api/v1/admin/payment/plans +GET /api/v1/admin/subscriptions +GET /api/v1/admin/subscriptions/{id} +GET /api/v1/admin/users/{id} +GET /api/v1/admin/users/{id}/subscriptions +``` + +实际插件先只开放只读管理员接口;不得把路径中的 `` 当作通配符,部署配置必须列出具体路径、方法和分页上限。现有 Core 没有 `/api/v1/plugin-host/*` 路由,V1 不调用或宣称该路径已存在。 + +### 6.3 V1 写操作边界 + +V1 框架和第一版订阅插件不实现余额扣款、订阅续期或撤销写操作。现有 Admin API 的多个调用不应拼成一笔购买,因为这样无法保证余额、订单、订阅和审计的单事务一致性。 + +未来要支持写操作,必须先在 Core 中增加版本化 Host Adapter 或等价的原子命令接口(需要 Core 代码、路由、审计和幂等存储变更),再由插件接入;这属于 V1.1,不是本轮“零 Core 改动”的交付物。 + +### 6.4 错误和重试 + +| HTTP | 含义 | 插件行为 | +|---|---|---| +| `401` | 凭据无效或已撤销 | 停止重试,提示重新注册/轮换。 | +| `403` | 管理员角色、会话或资源权限不足 | 不重试,记录操作者和资源。 | +| `409` | 幂等键冲突或业务状态冲突 | 查询 operation 状态后展示最终结果。 | +| `422` | 参数或余额业务校验失败 | 展示可读错误,不重试。 | +| `429` | Core 限流 | 按 `Retry-After` 有上限地重试。 | +| `5xx` | Core 暂时故障 | 只对明确幂等命令重试,指数退避。 | + +## 7. 插件注册、清单与生命周期 + +### 7.1 与 `.s2plugin` 分离 + +Business Plugin V1 不直接套用现有 `manifest.schema.json`。建议新增独立的业务插件清单,例如 `business-plugin-manifest.v1.json`: + +```json +{ + "schema_version": 1, + "plugin_id": "example.subscription", + "name": "Example Subscription Admin", + "version": "0.1.0", + "core_api_baseline": "sub2api-0.1.183", + "capabilities": ["subscription.admin.v1"], + "tested_core_versions": ["0.1.183"], + "backend": { "health_path": "/healthz" }, + "ui": { "entrypoint": "/admin" }, + "publisher": { "key_id": "publisher-key-id" } +} +``` + +清单只声明能力和兼容范围,不授予数据库、路由或 secret 权限。V1 由部署脚本/反向代理保存清单、校验签名和允许的 Core API 路径;当前 Core 不会读取该清单,也没有业务插件注册表。插件发布包/容器镜像仍应签名,但签名验证属于部署门禁,不应写成现有 Core 能力。 + +### 7.2 状态机 + +```text +registered -> disabled -> enabled -> draining -> disabled + │ │ │ + └────── incompatible └── error +``` + +- `registered`:部署层已登记清单和发布者,但未启用。 +- `disabled`:服务存在但不接收业务请求。 +- `enabled`:健康检查通过,允许插件后端调用列入 allowlist 的 Core API。 +- `draining`:停止接收新命令,等待进行中的幂等命令完成。 +- `error`:健康检查或 Core API 合约校验失败,默认 fail-closed。 +- `incompatible`:插件清单或 Core API 版本不兼容,不允许启用。 + +### 7.3 升级和回滚 + +- 插件版本独立于 `backend/cmd/server/VERSION` 和 `frontend/package.json`。 +- 升级前先执行健康检查、现有 Core API contract test 和插件自身数据备份检查。 +- 新版本不兼容时保持旧版本运行,不自动覆盖正在启用的实例。 +- 停用时等待进行中的命令完成;超过 drain 超时则拒绝新命令并标记待恢复。 +- 插件卸载不删除 Core 订阅、余额和审计数据。 + +## 8. 数据归属 + +### 8.1 Core 权威数据 + +- 用户身份和管理员授权; +- 余额账本; +- 套餐价格、额度、覆盖 Group 和购买快照; +- 用户订阅状态、期限、配额和 reserved; +- 余额扣减、订阅创建/续期、撤销和审计; +- 请求计费、额度预留、结算和幂等。 + +### 8.2 Plugin 可拥有的数据 + +- 插件管理员会话和本地角色映射; +- 插件 UI 偏好、筛选条件和缓存; +- Core API operation 的本地查询索引; +- 供应商或业务侧的非权威展示配置。 + +插件数据库中的订阅副本必须可删除、可重建、带来源版本和更新时间。它不作为网关放行请求的依据。 + +## 9. 前端和菜单集成 + +V1 通过现有的管理员自定义菜单嵌入,不修改 Core 前端路由: + +```text +Core 设置 -> custom_menu_items + id: example.subscription + visibility: admin + url: https://PLUGIN_ORIGIN/extensions/example.subscription/ + +Core `/custom/example.subscription` + └── sandbox iframe -> 反向代理 -> `127.0.0.1:PLUGIN_PORT` +``` + +`visibility=admin` 只负责隐藏普通账号的菜单入口,插件后端仍必须独立鉴权。现有 iframe 的 sandbox、跨端口 origin 和 Core JWT `localStorage` 使其不会自动共享 Core 登录态;因此 iframe 首屏显示插件登录页是 V1 的预期行为。插件也应提供“新窗口打开”,便于登录后保持自身会话。 + +生产部署建议把插件外部地址挂在与 Core 相同的 HTTPS 站点下,由 Nginx/Caddy 按路径反代到独立端口;这只减少浏览器跨域问题,不改变插件必须登录和服务端调用 Core 的事实。若使用独立 origin,必须在 Core CORS 中精确加入该 origin,禁止 `*`,并仅允许必要的 `Authorization` 请求头。 + +插件页面只调用自己的 `/plugin-api/*`,由插件后端调用 Core 现有认证和管理员 API。V1 不允许插件动态注入主应用 Vue 路由、修改核心菜单组件或覆盖全局 CSS;主应用只提供一个受权限控制的“业务插件”入口,插件内部菜单由插件自己管理。 + +## 10. 威胁模型与处置 + +| 威胁 | V1 处置 | +|---|---| +| XSS 窃取 Admin Key | 浏览器永远没有 Admin Key;Cookie HttpOnly;CSP 和输出编码。 | +| 插件前端伪造管理员命令 | 插件后端重新验证插件会话;Core 重新验证 Bearer token、管理员角色、操作者和资源权限。 | +| 插件后端日志泄露 secret | 统一日志脱敏,禁止记录 Authorization 和完整请求。 | +| 插件服务被攻破 | 限制 Token scope、TTL、出站地址和 Core API;立即撤销凭据。 | +| 重放余额购买 | Idempotency-Key + Core 事务记录 + 请求时间/nonce。 | +| CSRF | SameSite Cookie、CSRF token、Origin/Referer 校验。 | +| SSRF | 插件出站 allowlist;Core 现有 API 不接受任意 URL。 | +| 普通用户进入后台 | 插件登录调用 Core `/auth/me` 并要求 `role=admin`;所有插件路由默认 deny。 | +| 多实例状态漂移 | Core 是权威;插件状态通过健康检查和配置版本对齐。 | +| 插件卸载误删订阅 | 插件没有删除 Core 账本的权限,卸载只撤销凭据。 | + +必须明确:独立插件进程不是操作系统级沙箱,签名只证明发布者和完整性,不证明代码无漏洞。生产部署仍需要低权限运行、网络隔离和可撤销凭据。 + +## 11. 测试门禁 + +### 11.1 Core API Adapter 合约测试 + +- Core 登录、`/login/2fa`、`/auth/me`、access/refresh 过期和撤销; +- 普通用户、停用用户、无效会话和跨插件会话均返回 `403`; +- 只读 Admin API 的字段脱敏、分页上限和资源权限; +- Core 超时、`401/403/429/5xx`、刷新 token 和重新登录; +- V1 明确没有 `/api/v1/plugin-host/*`,测试不能把未来接口当成现有接口。 + +### 11.2 插件后端测试 + +- Core 登录代理、2FA、会话过期、登出和 CSRF; +- Core access/refresh token 不进入响应、页面、日志和异常堆栈; +- Core `401/403/429/5xx` 的处理和刷新失败后的重新登录; +- 只允许配置中列出的 Core API endpoint; +- 插件服务重启后会话失效或从插件自己的会话存储恢复,不能依赖 Core 用户表。 + +### 11.3 浏览器和部署测试 + +- 管理员可登录,普通用户无法登录或访问任何后台 API; +- 不同屏幕下页面无横向泄露和敏感字段; +- 浏览器 DevTools 的请求、下载和页面源中没有 Admin Key; +- HTTPS、反向代理、容器低权限、secret 文件权限和日志脱敏; +- 停用、升级、回滚、凭据撤销后所有写操作按预期失败。 + +## 12. 分阶段实施计划(不含本次代码) + +### Phase 0:冻结部署契约 + +- 选择外部服务部署方式(推荐同源反向代理 + 独立服务); +- 冻结插件端口、反向代理路径、`custom_menu_items` 字段和健康检查; +- 冻结允许调用的现有 Core API 路径、字段、分页和错误处理; +- 明确插件登录通过 Core `/auth/login`/`/login/2fa`,不创建用户表; +- 建立 Core token 会话销毁、Admin Key 过渡和日志脱敏方案。 + +### Phase 1:框架最小实现 + +- 独立服务目录、清单、发布者签名和部署兼容性检查; +- Plugin Backend 管理员登录和会话; +- Core access/refresh token 服务端会话适配器; +- 插件健康检查、启停、操作审计和同源入口; +- Core API allowlist、contract test 与本地示例插件。 + +### Phase 2:订阅插件试验 + +- 只读套餐、用户余额和订阅列表; +- 管理员操作页面、查询缓存和审计; +- 使用测试 Core 和测试账户,不连接生产余额; +- 余额购买、续费和撤销暂不实现,等待 Core 原子接口冻结。 + +### Phase 3:生产准备 + +- 未来 Core Host Adapter/短时 Plugin Access Token(若确认需要 Core 改动); +- step-up、密钥轮换和撤销; +- 多实例、备份恢复、升级回滚和故障演练; +- 通过安全、契约、浏览器和并发门禁后再启用。 + +## 13. 待确认事项 + +以下事项在写代码前必须确定: + +1. V1 的订阅插件只允许管理员操作;普通用户购买页不纳入本框架首版。 +2. 管理员登录是否按本文通过 Core `/auth/login` 和 `/login/2fa` 完成;确认不创建插件用户表。 +3. 插件采用独立服务端口 + 同源反向代理,还是独立 origin;需确定生产网络拓扑。 +4. V1 是否允许测试环境使用服务端专用 Admin Key;生产默认不使用,除非接受单一管理员审计语义。 +5. 插件是否允许保存只读缓存;无论选择何种缓存,Core 必须始终是权威来源。 +6. V1.1 是否需要真正无感 SSO 和 Core Host Adapter;若需要,单独立项修改 Core。 + +在这些问题确认前,不应开始实现插件协议、数据库表或前端页面。 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..d6bcb19 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,20 @@ +# Business Plugins + +这里存放独立业务插件的领域文档和适配说明。 + +## 框架入口 + +- [Business Plugin Framework V1](BUSINESS_PLUGIN_FRAMEWORK_V1.md) +- [Manifest V1](BUSINESS_PLUGIN_MANIFEST_V1.md) +- [Boundaries](BUSINESS_PLUGIN_BOUNDARIES.md) +- [Architecture](BUSINESS_PLUGIN_ARCHITECTURE.md) +- [Acceptance](BUSINESS_PLUGIN_ACCEPTANCE.md) +- [Development](BUSINESS_PLUGIN_DEVELOPMENT.md) + +## 领域插件 + +- `subscription-admin`:独立管理员只读订阅插件。它是框架的第一个领域样例,不是通用插件后台,也不负责安装或管理其他插件。实现与运行方式见 [`../plugins/subscription-admin/README.md`](../plugins/subscription-admin/README.md)。 + +## 现有 `.s2plugin` + +现有 OpenAI OAuth transport 插件不属于本仓库;Business Plugin V1 与 Core 的 transport 插件使用不同的包、运行时和生命周期协议。 diff --git a/docs/REPOSITORY_SCOPE.md b/docs/REPOSITORY_SCOPE.md new file mode 100644 index 0000000..93e4098 --- /dev/null +++ b/docs/REPOSITORY_SCOPE.md @@ -0,0 +1,25 @@ +# Repository Scope + +本仓库只承载独立业务插件及其版本化契约,不承载 Sub2API Core。 + +## 依赖关系 + +```text +Sub2API Core(官方仓库) + ^ + | 公开 HTTP API / 管理员鉴权 / custom_menu_items + | +独立插件服务(本仓库) +``` + +插件不得依赖 Core 的 `internal` 包、Ent 生成代码、PostgreSQL、Redis 或 +Core 前端源码。每个插件拥有自己的版本、端口、服务进程、UI、配置和发布 +产物,并在清单中声明兼容的 Core 基线。 + +## 仓库边界 + +- Core 的用户、余额、订单、订阅、配额、计费和用量数据仍由 Core 管理。 +- 插件只通过服务端 allowlist 调用 Core API。 +- 浏览器只访问插件自己的会话 API,不接触 Core JWT 或 Admin Key。 +- 插件菜单通过 `custom_menu_items` 注入;停用或卸载只移除插件自己的菜单。 +- 订阅管理是可选领域插件,不是通用插件控制面的一部分。 diff --git a/docs/SUBSCRIPTION_PLUGIN_V1_RFC.md b/docs/SUBSCRIPTION_PLUGIN_V1_RFC.md new file mode 100644 index 0000000..c8511e0 --- /dev/null +++ b/docs/SUBSCRIPTION_PLUGIN_V1_RFC.md @@ -0,0 +1,254 @@ +# 余额订阅业务插件 V1 RFC + +状态:V1 只读试验实现已落库;余额购买、续费、撤销仍为 V1.1 Draft + +本文定义基于 [`PLUGIN_FRAMEWORK_V1_RFC.md`](./PLUGIN_FRAMEWORK_V1_RFC.md) 的第一个业务插件试验,对应实现为 `plugins/subscription-admin`。插件是独立端口的管理员后台,复用 Sub2API Core 的管理员鉴权,不创建 Core 用户表,也不直接连接 Core 数据库。当前实现已完成框架、管理员会话、只读套餐/余额/订阅/审计联调;余额购买、续费和撤销写操作后置到 Core 原子接口冻结之后。 + +## 1. 目标和范围 + +### 1.1 V1 目标 + +- 提供独立的管理员订阅后台,普通账号拒绝登录和访问; +- 插件登录调用 Core 现有 `/api/v1/auth/login`、`/api/v1/auth/login/2fa` 和 `/api/v1/auth/me`,不维护第二套用户密码; +- 展示 Core 中的套餐、用户余额和订阅实例状态; +- 通过现有 `custom_menu_items` 的 `visibility=admin` 入口嵌入 Core 页面,也支持新窗口打开; +- 插件可独立升级、停用和回滚,不影响 Core 网关、余额账本和已有订阅; +- 记录未来订阅写操作所需的业务语义、幂等和审计契约,但本阶段不执行扣款。 + +### 1.2 V1 非目标 + +- 不接入支付宝、微信、Stripe 或其他外部支付渠道; +- 不允许普通用户登录插件后台或在插件内自助购买; +- 不把余额、套餐、订阅额度复制成插件自己的权威账本; +- 不把每次网关请求改成调用插件 RPC; +- 不通过多个现有 Admin API 调用拼接一次购买; +- 不在本次设计阶段修改 Core 代码、数据库迁移、现有前端路由或 `.s2plugin` ABI。 + +## 2. 业务语义(供后续 Core 原子接口使用) + +订阅插件以后需要保持当前业务语义,写操作必须由 Core 在单事务内完成: + +1. **支持多个同档位**:同一用户可以拥有多个相同套餐/档位的订阅实例。每个实例有独立的 `subscription_id`、期限、配额、用量和审计记录;实例数量受套餐的上限字段约束。 +2. **支持单独续费**:续费请求必须指定 `subscription_id`,只延长目标实例的期限或按 Core 规则生成续费记录,不影响同用户的其他同档位实例。 +3. **不可由用户取消**:插件和用户端不提供“取消订阅”入口。管理员撤销属于单独的受控操作,需 Core 权限、原因、二次确认和审计;它不等同于用户取消。 +4. **购买时快照**:价格、货币、有效期、额度、Group 覆盖和实例规则以购买时版本写入 Core 快照,后续编辑套餐不静默改写已购买实例。 +5. **余额付款**:余额扣减、订阅创建/续费、余额流水、幂等记录和审计必须在 Core 同一个事务边界内完成。 + +这些语义是插件的业务约束,不代表当前 Core 已经提供了对应的业务插件 API。第一阶段只验证读取现有订阅数据,写入契约单独评审。 + +## 3. 推荐拓扑 + +```text +Core 管理后台 + └─ custom_menu_items (visibility=admin) + └─ sandbox iframe / 新窗口 + └─ 反向代理 -> Plugin `127.0.0.1:PLUGIN_PORT` + ├─ Plugin /login -> Core /api/v1/auth/login (+ /login/2fa) + ├─ Plugin /auth/me -> 只允许 role=admin + ├─ HttpOnly 插件会话(不建 Core 用户表) + └─ 服务端 Bearer Core JWT -> 现有 Core Admin API(V1 只读) +``` + +插件 UI 只访问自己的 BFF;Core JWT、refresh token 和 Admin API Key 只在插件服务端会话或 secret 中出现。iframe 不会自动继承 Core `localStorage` 登录态,因此 V1 首屏显示插件登录页属于预期行为。sandbox 或第三方 Cookie 策略可能导致嵌入会话刷新后失效,插件必须提供新窗口登录路径;登录密码只在一次转发请求中经过插件后端,不落库、不写日志。 + +## 4. 权威边界 + +### 4.1 必须留在 Core + +- 用户身份、管理员权限和资源授权; +- 用户余额和余额流水; +- 套餐价格、货币、有效期、额度、包含的 Group、实例模式和上限; +- 购买时的套餐快照; +- 订阅实例状态、期限、配额、reserved 和用量; +- 余额扣减、订阅创建/续费、撤销、退款/补偿; +- 额度预留、实际结算、幂等、防超卖、缓存失效和网关放行判定。 + +### 4.2 插件可以负责 + +- 管理员登录代理、插件会话和插件内角色; +- 套餐、用户和订阅的分页筛选与展示; +- 只读缓存、操作结果页和管理员审计视图; +- 未来写操作的确认表单,但提交必须调用 Core 原子命令; +- 插件 UI 的版本和发布。 + +插件的本地副本可删除、可重建、带来源版本和更新时间,不能作为网关授权或扣费依据。 + +## 5. V1 现有 Core API 适配 + +V1 不调用尚不存在的 `/api/v1/plugin-host/*`。插件后端通过严格 allowlist 调用 Core 已有接口,具体路径按部署的 Core 版本冻结: + +```http +POST /api/v1/auth/login +POST /api/v1/auth/login/2fa +POST /api/v1/auth/refresh +POST /api/v1/auth/logout +GET /api/v1/auth/me +GET /api/v1/settings/public +GET /api/v1/admin/payment/plans +GET /api/v1/admin/subscriptions +GET /api/v1/admin/subscriptions/{id} +GET /api/v1/admin/users/{id} +GET /api/v1/admin/users/{id}/subscriptions +``` + +请求头只由插件后端添加: + +```http +Authorization: Bearer CORE_ACCESS_TOKEN +X-Request-Id: UNIQUE_REQUEST_ID +``` + +列表接口必须设置分页、字段最小化、超时和审计。插件配置列出具体方法和路径,不能使用任意 URL 或宽泛通配符。Core 返回 `401` 时,插件先尝试一次 refresh;refresh 失败就销毁插件会话并要求重新登录。 + +## 6. 未来写操作契约(V1.1,当前不实现) + +现有 Core Admin API 的 `assign`、`extend`、`revoke` 等操作属于 Core 管理面,当前不把它们组合成余额购买流程。要在插件内支持余额购买,Core 需要增加版本化原子命令或等价 Host Adapter,并在 Core 内完成余额、订单/购买记录、订阅和审计的一致性事务。 + +### 6.1 余额购买请求示例 + +```http +POST /api/v1/plugin-host/v1/subscription-purchases/balance +Authorization: Bearer PLUGIN_ACCESS_TOKEN +Idempotency-Key: subscription-purchase-UNIQUE_ID +Content-Type: application/json + +{ + "user_id": 123, + "plan_id": 12, + "expected_plan_version": 4, + "expected_price": "19.00", + "currency": "USD", + "request_id": "UNIQUE_REQUEST_ID" +} +``` + +该路径是未来接口示例,当前 Core 没有实现。Core 必须重新读取当前套餐和余额,`expected_*` 只用于发现界面陈旧;价格以字符串 decimal 处理,不使用二进制浮点数。 + +### 6.2 Core 事务要求 + +未来 Core 原子命令至少需要: + +1. 校验操作者为管理员并通过资源级授权; +2. 锁定用户余额、套餐购买上限和目标订阅资源; +3. 校验套餐可售、Group 覆盖、余额和实例上限; +4. 写入购买快照; +5. 扣余额并写余额流水; +6. 创建新实例或仅续费指定 `subscription_id`; +7. 写唯一 operation、幂等记录和管理员审计; +8. 事务提交后失效相关缓存。 + +任一步失败都回滚整笔命令。插件不接触 SQL transaction,也不执行失败后的自行补偿。 + +### 6.3 操作状态和重试 + +```text +accepted -> processing -> completed + └── failed +``` + +同一 `Idempotency-Key` 重试返回第一次操作结果,不重复扣款。网络超时后插件查询 operation 状态,不再次创建购买。`401/403` 不重试;`429` 按 `Retry-After` 有上限退避;只有明确幂等命令才允许对 `5xx` 重试。 + +## 7. 同档位实例和续费规则 + +未来实现必须覆盖以下测试矩阵: + +| 场景 | 预期结果 | +|---|---| +| 同一用户购买两个相同档位 | 产生两个独立实例,各自有期限、配额和 `subscription_id`。 | +| 续费实例 A | 只改变实例 A;实例 B 的期限和配额保持不变。 | +| 达到实例上限 | 返回稳定业务错误,不扣余额、不创建半成品订阅。 | +| 用户尝试取消 | 插件没有取消入口;Core 用户接口也不提供用户自助取消语义。 | +| 管理员撤销 | 走单独的受控 Core 操作,要求原因、审计和明确的退款/补偿规则。 | +| 两个管理员并发购买 | 由 Core 锁和幂等保证不超卖;失败方查询最终状态。 | + +## 8. 套餐快照和变更 + +推荐后续 Core 设计提供不可变套餐版本或购买快照,至少包含价格、货币、有效期、三类额度、覆盖 Group、实例模式和上限。当前 schema 的运行时 Group 覆盖和套餐字段存在可变性,不能在本 RFC 中宣称已经提供完整快照保证。 + +套餐下架只影响新购买;已有订阅如何处理由 Core 现有授权和计费规则决定。若要迁移已有实例,需要单独的预览、影响数量、确认 token、幂等键和审计命令。 + +## 9. 管理员 UI V1 + +首版只读页面建议顺序: + +1. 概览:Core 连接状态、插件版本、最后同步时间和健康状态; +2. 套餐:价格、货币、有效期、额度、覆盖 Group 和可售状态; +3. 用户订阅:按用户 ID、脱敏名称、订阅状态、实例档位和到期时间查询; +4. 订阅详情:展示单个 `subscription_id` 的期限、窗口额度、用量和来源版本; +5. 操作记录:展示操作者、Core request ID、结果和时间; +6. 设置:Core 地址、allowlist、会话/凭据状态和健康检查;secret 只允许轮换,不允许回显。 + +余额购买、单独续费和管理员撤销在 V1 只显示“未启用”状态,不渲染可提交按钮,避免误触发现有非原子接口。 + +## 10. 安全验收 + +- 页面源码、网络请求、下载文件和浏览器存储中没有 Admin Key 或 Core JWT; +- 普通用户使用 Core JWT 登录插件返回 `403`,直接访问插件 API 也返回 `403`; +- Core access/refresh token 不进入插件响应、页面、日志和异常堆栈; +- 插件后端只访问配置中的 Core API 路径,禁止 SSRF 和任意 URL 代理; +- 插件服务停用后,已有订阅仍按 Core 原有网关鉴权和计费逻辑运行; +- 清空插件本地缓存后,可从 Core 重建只读列表,不影响核心余额和订阅; +- 用户端没有取消订阅入口,管理员撤销(未来启用时)必须有原因、审计和幂等键。 + +## 11. 测试计划 + +### 11.1 插件后端 + +- 管理员登录、2FA、普通用户拒绝、会话过期、登出和 CSRF; +- Core token refresh、撤销和 Core `401/403/429/5xx` 处理; +- 套餐/余额/订阅查询的分页、字段脱敏、超时和缓存重建; +- allowlist 拒绝未声明路径、任意 URL 和跨插件会话; +- 多实例会话存储和服务重启后的会话策略。 + +### 11.2 未来 Core 写操作 + +- 两个同档位实例、目标实例单独续费和实例上限; +- 用户取消入口不存在,管理员撤销的审计和补偿规则; +- 余额不足、套餐下架、价格版本冲突、并发购买和幂等重试; +- 事务失败时余额、订单、订阅和审计均保持原状; +- 购买快照内容不可变、缓存只在提交后失效。 + +### 11.3 浏览器和部署 + +- 管理员可在 iframe 和新窗口完成登录;普通用户菜单不可见且 API 拒绝; +- 425px、900px、1440px 下无横向溢出、遮挡或敏感字段泄露; +- DevTools 请求、下载、页面源和日志中没有 secret; +- 反向代理、HTTPS、低权限运行、停用、升级和回滚流程可恢复。 + +## 12. 分阶段实施计划 + +### Phase 0:框架契约 + +- 冻结插件端口、反向代理路径、`custom_menu_items` 字段和健康检查; +- 冻结 Core 登录/2FA、管理员角色判断和现有只读 API allowlist; +- 确认会话存储、Core token 生命周期、日志脱敏和 secret 轮换; +- 不改 Core 代码、迁移、现有插件 ABI 或前端路由。 + +### Phase 1:插件框架最小实现 + +- 独立服务目录、清单、签名和部署兼容性检查; +- Core 登录代理、管理员角色校验、HttpOnly 插件会话和 CSRF; +- Core API BFF、健康检查、审计和只读示例页; +- 本地 contract test、浏览器验收和反向代理样例。 + +### Phase 2:订阅只读插件 + +- 套餐、用户余额、订阅实例和操作记录只读查询; +- 同档位实例/单独续费/不可取消语义的 UI 展示与测试数据; +- 只接入测试 Core,不连接生产余额。 + +### Phase 3:单独立项的 Core 写能力 + +- 评审并实现 Core 原子余额购买/续费/撤销接口; +- 增加 Core 幂等存储、审计和购买快照后,再接入插件写操作; +- 通过并发、事务、浏览器和回滚门禁后才启用生产 scope。 + +## 13. 实施前检查清单 + +- [ ] 插件只允许 Core `role=admin` 登录,且不创建用户表。 +- [ ] 独立端口、反向代理路径和 `visibility=admin` 菜单入口已确定。 +- [ ] iframe 登录页和新窗口登录页均可用,已知晓 V1 不提供无感 SSO。 +- [ ] 只读 Core API allowlist、分页、字段脱敏和缓存策略已冻结。 +- [ ] 同档位多实例、单独续费、用户不可取消和管理员撤销规则已确认。 +- [ ] 余额购买写操作明确等待 Core 原子接口,不使用现有多个接口拼接。 +- [ ] 测试 Core、测试管理员和测试订阅数据已准备,生产余额尚未接入。 diff --git a/plugins/plugin-admin/.env.example b/plugins/plugin-admin/.env.example new file mode 100644 index 0000000..4647fe8 --- /dev/null +++ b/plugins/plugin-admin/.env.example @@ -0,0 +1,14 @@ +CORE_BASE_URL=http://127.0.0.1:8080 +PLUGIN_ENV=production +PLUGIN_HOST=127.0.0.1 +PLUGIN_PORT=8090 +PLUGIN_REGISTRY_DIR=/var/lib/sub2api/plugin-admin +PLUGIN_PUBLIC_BASE_PATH=/extensions/qiu.plugin-admin +PLUGIN_COOKIE_PATH=/extensions/qiu.plugin-admin/ +PLUGIN_COOKIE_SECURE=false +PLUGIN_COOKIE_SAMESITE=lax +PLUGIN_FRAME_ANCESTORS='self' +PLUGIN_ALLOW_UNSIGNED=false +PLUGIN_CONFIG_KEY=generate-and-replace-with-a-random-32-byte-secret +# JSON object: {"publisher-key-id":"BASE64_ED25519_PUBLIC_KEY"} +PLUGIN_TRUSTED_PUBLISHERS={} diff --git a/plugins/plugin-admin/Makefile b/plugins/plugin-admin/Makefile new file mode 100644 index 0000000..ac9c571 --- /dev/null +++ b/plugins/plugin-admin/Makefile @@ -0,0 +1,15 @@ +.PHONY: test build check browser-check + +test: + go test ./... -count=1 + +build: + mkdir -p bin + CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o bin/plugin-admin . + +check: test + node --check ui/app.js + git diff --check + +browser-check: + ./test/run-browser-check.sh diff --git a/plugins/plugin-admin/README.md b/plugins/plugin-admin/README.md new file mode 100644 index 0000000..70e3373 --- /dev/null +++ b/plugins/plugin-admin/README.md @@ -0,0 +1,76 @@ +# Sub2API Business Plugin Control Plane V1 + +This directory contains the independent administrator-only control plane for +Business Plugins. It is deliberately separate from Sub2API Core and from the +existing `.s2plugin` OpenAI OAuth transport runtime. + +The control plane owns the plugin catalog, signed package verification, +revision directories, lifecycle state, encrypted configuration metadata, +menu preview/apply, and its own audit log. Core remains authoritative for +users, administrator roles, balances, subscriptions, billing, and audit +records. The service never connects to Core PostgreSQL/Redis and never sends a +Core JWT or Admin Key to the browser. + +## Run locally + +```sh +CORE_BASE_URL=http://127.0.0.1:8080 \ +PLUGIN_HOST=127.0.0.1 PLUGIN_PORT=8090 \ +PLUGIN_REGISTRY_DIR=./data \ +PLUGIN_ENV=development \ +PLUGIN_ALLOW_UNSIGNED=true \ +PLUGIN_CONFIG_KEY=local-development-secret-at-least-32-chars \ +go run . +``` + +`PLUGIN_ALLOW_UNSIGNED=true` is a development-only switch. Production +packages must contain `signature.json`, use Ed25519, and match a trusted key +from `PLUGIN_TRUSTED_PUBLISHERS` (a JSON object of key ID to base64 public +key). `PLUGIN_CONFIG_KEY` is required in every environment; use a randomly +generated secret in production and keep it stable across restarts so encrypted +plugin configuration remains decryptable. + +Open `/admin/` directly or expose the service through the reverse proxy in +`deploy/`. The first login is the existing Core administrator login; no plugin +user table is created. The control plane stores only a short-lived server-side +session and encrypted plugin configuration. + +## Control-plane endpoints + +```text +GET /healthz +GET /readyz +POST /login POST /login/2fa POST /logout +GET /api/me GET /api/plugins GET /api/plugins/{id} +POST /api/plugins/{id}/install (multipart field: package) +POST /api/plugins/{id}/upgrade (multipart field: package) +POST /api/plugins/{id}/enable|disable|rollback|uninstall +GET|PUT /api/plugins/{id}/config +POST /api/plugins/{id}/menu-preview|menu-apply +POST /api/menu-items/preview|apply (JSON: {"plugin_id":"..."}) +GET /api/audit +``` + +Every mutation requires the plugin CSRF token and an `Idempotency-Key`. A +mutation returns an operation ID even when it completes synchronously. Failed +installation and upgrade never replace the active revision. Uninstall is +allowed only after disable and removes plugin files, not Core data. + +## Plugin package + +Packages are ZIP files with `manifest.json`, optional detached +`signature.json`, and declared `ui/` files. A package may also include +`service/` files when the control plane owns the plugin process; external +service packages omit `backend.command` and require a configured loopback +`service_url` before enabling. SHA-256 hashes in the manifest cover every +declared file. Absolute paths, traversal, duplicate entries, symlinks, +undeclared hashes, oversized files, unknown manifest fields, and untrusted +publishers are rejected before staging. Installation uses a per-plugin +revision directory and an atomic registry JSON update. + +## Deliberate V1 limits + +The control plane does not register Core routes, change Core migrations, run +arbitrary proxy URLs, provide transparent iframe SSO, or move billing and +subscription hot-path logic out of Core. A subscription manager remains a +separate business plugin that consumes its own typed Core API adapter. diff --git a/plugins/plugin-admin/build.sh b/plugins/plugin-admin/build.sh new file mode 100755 index 0000000..da571c8 --- /dev/null +++ b/plugins/plugin-admin/build.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env sh +set -eu +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +mkdir -p "$ROOT/bin" +CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o "$ROOT/bin/plugin-admin" "$ROOT" diff --git a/plugins/plugin-admin/business-plugin-manifest.v1.json b/plugins/plugin-admin/business-plugin-manifest.v1.json new file mode 100644 index 0000000..1b1684b --- /dev/null +++ b/plugins/plugin-admin/business-plugin-manifest.v1.json @@ -0,0 +1,33 @@ +{ + "schema_version": 1, + "plugin_id": "qiu.plugin-admin", + "name": "Business Plugin Control Plane", + "version": "1.0.0", + "core_api_baseline": "sub2api-0.1.183", + "tested_core_versions": ["0.1.183"], + "capabilities": ["plugin.admin.v1"], + "backend": { + "health_path": "/healthz", + "readiness_path": "/readyz", + "listen_env": "PLUGIN_PORT" + }, + "ui": { + "entrypoint": "ui/index.html", + "menu": { + "id": "qiu.plugin-admin", + "label": "插件管理", + "visibility": "admin", + "sort_order": 190 + } + }, + "publisher": { "key_id": "qiu-plugin-admin-dev" }, + "core_api_allowlist": [ + "POST /api/v1/auth/login", + "POST /api/v1/auth/login/2fa", + "POST /api/v1/auth/refresh", + "POST /api/v1/auth/logout", + "GET /api/v1/auth/me", + "GET /api/v1/settings/public", + "GET /api/v1/admin/settings" + ] +} diff --git a/plugins/plugin-admin/deploy/Caddyfile.example b/plugins/plugin-admin/deploy/Caddyfile.example new file mode 100644 index 0000000..048c872 --- /dev/null +++ b/plugins/plugin-admin/deploy/Caddyfile.example @@ -0,0 +1,8 @@ +CORE_ORIGIN { + handle_path /extensions/qiu.plugin-admin/* { + reverse_proxy 127.0.0.1:8090 + } +} + +# Set PLUGIN_PUBLIC_BASE_PATH=/extensions/qiu.plugin-admin and +# PLUGIN_COOKIE_PATH=/extensions/qiu.plugin-admin/. diff --git a/plugins/plugin-admin/deploy/custom-menu-item.json b/plugins/plugin-admin/deploy/custom-menu-item.json new file mode 100644 index 0000000..3cf6e89 --- /dev/null +++ b/plugins/plugin-admin/deploy/custom-menu-item.json @@ -0,0 +1,7 @@ +{ + "id": "qiu.plugin-admin", + "label": "插件管理", + "url": "https://CORE_ORIGIN/extensions/qiu.plugin-admin/", + "visibility": "admin", + "sort_order": 190 +} diff --git a/plugins/plugin-admin/deploy/nginx.conf.example b/plugins/plugin-admin/deploy/nginx.conf.example new file mode 100644 index 0000000..d3a7430 --- /dev/null +++ b/plugins/plugin-admin/deploy/nginx.conf.example @@ -0,0 +1,13 @@ +location /extensions/qiu.plugin-admin/ { + proxy_pass http://127.0.0.1:8090/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 30s; + proxy_send_timeout 30s; +} + +# Set PLUGIN_PUBLIC_BASE_PATH and PLUGIN_COOKIE_PATH to this same path. +# Keep the service bound to loopback and expose it only through HTTPS. diff --git a/plugins/plugin-admin/deploy/systemd.service.example b/plugins/plugin-admin/deploy/systemd.service.example new file mode 100644 index 0000000..ece7b3d --- /dev/null +++ b/plugins/plugin-admin/deploy/systemd.service.example @@ -0,0 +1,22 @@ +[Unit] +Description=Sub2API Business Plugin Control Plane +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=sub2api-plugin +Group=sub2api-plugin +WorkingDirectory=/opt/sub2api/plugin-admin +EnvironmentFile=/etc/sub2api/plugin-admin.env +ExecStart=/opt/sub2api/plugin-admin/bin/plugin-admin +Restart=on-failure +RestartSec=3 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/sub2api/plugin-admin + +[Install] +WantedBy=multi-user.target diff --git a/plugins/plugin-admin/go.mod b/plugins/plugin-admin/go.mod new file mode 100644 index 0000000..47e6c8c --- /dev/null +++ b/plugins/plugin-admin/go.mod @@ -0,0 +1,3 @@ +module git.awaioi.com/awaioi/sub2api-add/plugins/plugin-admin + +go 1.23 diff --git a/plugins/plugin-admin/internal/manifest/manifest.go b/plugins/plugin-admin/internal/manifest/manifest.go new file mode 100644 index 0000000..f0a2ed3 --- /dev/null +++ b/plugins/plugin-admin/internal/manifest/manifest.go @@ -0,0 +1,414 @@ +// Package manifest validates the standalone Business Plugin V1 manifest. +package manifest + +import ( + "bytes" + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "os" + "regexp" + "sort" + "strings" +) + +var ( + pluginIDPattern = regexp.MustCompile(`^[a-z0-9]+(?:[._-][a-z0-9]+)+$`) + versionPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$`) + methodPattern = regexp.MustCompile(`^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)$`) + pathParam = regexp.MustCompile(`^\{[a-zA-Z][a-zA-Z0-9_-]*\}$`) + sha256Pattern = regexp.MustCompile(`^[a-f0-9]{64}$`) +) + +// RequiredAllowlist contains the Core endpoints needed by every plugin BFF. +// Domain-specific read/write endpoints must be appended by each plugin. +var requiredAllowlist = []string{ + "POST /api/v1/auth/login", + "POST /api/v1/auth/login/2fa", + "POST /api/v1/auth/refresh", + "POST /api/v1/auth/logout", + "GET /api/v1/auth/me", + "GET /api/v1/settings/public", +} + +type Manifest struct { + SchemaVersion int `json:"schema_version"` + PluginID string `json:"plugin_id"` + Name string `json:"name"` + Version string `json:"version"` + CoreAPIBaseline string `json:"core_api_baseline"` + Capabilities []string `json:"capabilities"` + TestedCoreVersions []string `json:"tested_core_versions"` + Backend Backend `json:"backend"` + UI UI `json:"ui"` + Publisher Publisher `json:"publisher"` + CoreAPIAllowlist []string `json:"core_api_allowlist"` + Files map[string]string `json:"files,omitempty"` +} + +type Backend struct { + HealthPath string `json:"health_path"` + ReadinessPath string `json:"readiness_path"` + ListenEnv string `json:"listen_env"` + Command string `json:"command,omitempty"` +} + +type UI struct { + Entrypoint string `json:"entrypoint"` + Menu Menu `json:"menu"` +} + +type Menu struct { + ID string `json:"id"` + Label string `json:"label"` + Visibility string `json:"visibility"` + SortOrder int `json:"sort_order"` + URL string `json:"url,omitempty"` +} + +type Publisher struct { + KeyID string `json:"key_id"` +} + +type Signature struct { + Algorithm string `json:"algorithm"` + KeyID string `json:"key_id"` + Signature string `json:"signature"` +} + +// Compatibility is the control-plane decision for the current Core version. +type Compatibility struct { + Compatible bool `json:"compatible"` + Tested bool `json:"tested"` + Status string `json:"status"` + Message string `json:"message"` +} + +func Load(path string) (Manifest, []byte, error) { + raw, err := os.ReadFile(path) + if err != nil { + return Manifest{}, nil, err + } + var m Manifest + dec := json.NewDecoder(strings.NewReader(string(raw))) + dec.DisallowUnknownFields() + if err := rejectDuplicateJSONKeys(raw); err != nil { + return Manifest{}, nil, err + } + if err := dec.Decode(&m); err != nil { + return Manifest{}, nil, fmt.Errorf("decode manifest: %w", err) + } + var trailing any + if err := dec.Decode(&trailing); err != io.EOF { + if err == nil { + return Manifest{}, nil, errors.New("manifest contains trailing JSON") + } + return Manifest{}, nil, fmt.Errorf("decode manifest trailing data: %w", err) + } + if err := Validate(m); err != nil { + return Manifest{}, nil, err + } + return m, raw, nil +} + +func Validate(m Manifest) error { + if m.SchemaVersion != 1 { + return errors.New("schema_version must be 1") + } + if !pluginIDPattern.MatchString(m.PluginID) || len(m.PluginID) > 160 { + return errors.New("invalid plugin_id") + } + if strings.TrimSpace(m.Name) == "" || len(m.Name) > 160 { + return errors.New("name is required and must be at most 160 characters") + } + if !versionPattern.MatchString(strings.TrimPrefix(m.Version, "v")) { + return errors.New("invalid plugin version") + } + baseline := strings.TrimPrefix(strings.TrimPrefix(m.CoreAPIBaseline, "sub2api-"), "v") + if !versionPattern.MatchString(baseline) { + return errors.New("invalid core_api_baseline") + } + if len(m.Capabilities) == 0 { + return errors.New("capabilities must contain at least one capability") + } + seenCaps := map[string]struct{}{} + for _, capability := range m.Capabilities { + if !pluginIDPattern.MatchString(capability) || len(capability) > 160 { + return fmt.Errorf("invalid capability: %s", capability) + } + if _, ok := seenCaps[capability]; ok { + return fmt.Errorf("duplicate capability: %s", capability) + } + seenCaps[capability] = struct{}{} + } + for _, version := range m.TestedCoreVersions { + if !versionPattern.MatchString(strings.TrimPrefix(version, "v")) { + return fmt.Errorf("invalid tested_core_versions entry: %s", version) + } + } + if err := validateEndpointPath(m.Backend.HealthPath); err != nil { + return fmt.Errorf("backend.health_path: %w", err) + } + if err := validateEndpointPath(m.Backend.ReadinessPath); err != nil { + return fmt.Errorf("backend.readiness_path: %w", err) + } + if m.Backend.Command != "" { + if err := validateRelativePath(m.Backend.Command); err != nil || !strings.HasPrefix(strings.ReplaceAll(m.Backend.Command, "\\", "/"), "service/") { + return errors.New("backend.command must be a safe path under service/") + } + } + if strings.TrimSpace(m.Backend.ListenEnv) == "" || !regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`).MatchString(m.Backend.ListenEnv) { + return errors.New("backend.listen_env is invalid") + } + if err := validateUIEntrypoint(m.UI.Entrypoint); err != nil { + return fmt.Errorf("ui.entrypoint: %w", err) + } + if m.UI.Menu.ID != m.PluginID || strings.TrimSpace(m.UI.Menu.Label) == "" || len(m.UI.Menu.Label) > 160 || m.UI.Menu.Visibility != "admin" || m.UI.Menu.SortOrder < 0 { + return errors.New("ui.menu must bind to plugin_id, use admin visibility, and have a valid label/order") + } + if m.UI.Menu.URL != "" { + u, err := url.Parse(strings.TrimSpace(m.UI.Menu.URL)) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return errors.New("ui.menu.url must be an absolute http(s) URL without credentials or query") + } + } + if strings.TrimSpace(m.Publisher.KeyID) == "" || len(m.Publisher.KeyID) > 160 { + return errors.New("publisher.key_id is required") + } + if len(m.CoreAPIAllowlist) < len(requiredAllowlist) { + return fmt.Errorf("core_api_allowlist must contain at least %d entries", len(requiredAllowlist)) + } + seen := map[string]struct{}{} + for _, entry := range m.CoreAPIAllowlist { + if err := validateAllowlistEntry(entry); err != nil { + return err + } + if _, ok := seen[entry]; ok { + return fmt.Errorf("duplicate allowlist entry: %s", entry) + } + seen[entry] = struct{}{} + } + for _, required := range requiredAllowlist { + if _, ok := seen[required]; !ok { + return fmt.Errorf("missing required allowlist entry: %s", required) + } + } + for path, hash := range m.Files { + if err := validateRelativePath(path); err != nil || !sha256Pattern.MatchString(hash) { + return fmt.Errorf("invalid file hash declaration: %s", path) + } + } + if len(m.Files) > 0 && strings.HasPrefix(m.UI.Entrypoint, "ui/") { + if _, ok := m.Files[m.UI.Entrypoint]; !ok { + return errors.New("ui.entrypoint is missing from files") + } + } + return nil +} + +func validatePluginPath(value string) error { + p := strings.ReplaceAll(strings.TrimSpace(value), "\\", "/") + if p == "" || strings.HasPrefix(p, "/") || strings.Contains(p, "\x00") || strings.Contains(p, "..") || strings.Contains(p, "*") || strings.Contains(p, "?") || strings.Contains(p, "#") || strings.Contains(p, ":") { + return errors.New("must be a safe plugin path") + } + return nil +} + +func validateEndpointPath(value string) error { + p := strings.ReplaceAll(strings.TrimSpace(value), "\\", "/") + if p == "" || !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") || strings.Contains(p, "\x00") || strings.Contains(p, "..") || strings.ContainsAny(p, "*?#") { + return errors.New("must be a safe absolute endpoint path") + } + return nil +} + +func validateRelativePath(value string) error { + p := strings.ReplaceAll(strings.TrimSpace(value), "\\", "/") + // Reject rooted paths, traversal, URL/drive syntax, and glob/query fragments. + if p == "" || strings.HasPrefix(p, "/") || strings.Contains(p, "\x00") || strings.Contains(p, "..") || strings.Contains(p, "*") || strings.Contains(p, "?") || strings.Contains(p, "#") || strings.Contains(p, ":") { + return errors.New("must be a safe relative path") + } + return nil +} + +func validateUIEntrypoint(value string) error { + p := strings.TrimSpace(strings.ReplaceAll(value, "\\", "/")) + if p == "/admin" || p == "/admin/" { + return nil + } + return validateRelativePath(p) +} + +func validateAllowlistEntry(entry string) error { + parts := strings.Fields(entry) + if len(parts) != 2 || !methodPattern.MatchString(parts[0]) { + return fmt.Errorf("invalid core_api_allowlist entry: %s", entry) + } + p := parts[1] + if !strings.HasPrefix(p, "/api/v1/") || strings.ContainsAny(p, "?#*") || strings.Contains(p, "//") || strings.Contains(p, "..") { + return fmt.Errorf("core_api_allowlist entry must be an exact Core path: %s", entry) + } + for _, segment := range strings.Split(strings.TrimPrefix(p, "/"), "/") { + if strings.Contains(segment, "{") || strings.Contains(segment, "}") { + if !pathParam.MatchString(segment) { + return fmt.Errorf("invalid path parameter in allowlist entry: %s", entry) + } + } + } + return nil +} + +func VerifySignature(manifestBytes, signatureBytes, publicKeyBytes []byte) error { + if err := rejectDuplicateJSONKeys(signatureBytes); err != nil { + return err + } + var signature Signature + dec := json.NewDecoder(strings.NewReader(string(signatureBytes))) + dec.DisallowUnknownFields() + if err := dec.Decode(&signature); err != nil { + return fmt.Errorf("decode signature: %w", err) + } + var trailing any + if err := dec.Decode(&trailing); err != io.EOF { + return errors.New("signature contains trailing JSON") + } + if signature.Algorithm != "ed25519" || strings.TrimSpace(signature.KeyID) == "" { + return errors.New("signature must use ed25519 and include key_id") + } + publicKey, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(publicKeyBytes))) + if err != nil || len(publicKey) != ed25519.PublicKeySize { + return errors.New("invalid base64 ed25519 public key") + } + sig, err := base64.StdEncoding.DecodeString(signature.Signature) + if err != nil || len(sig) != ed25519.SignatureSize { + return errors.New("invalid base64 ed25519 signature") + } + if !ed25519.Verify(ed25519.PublicKey(publicKey), manifestBytes, sig) { + return errors.New("manifest signature verification failed") + } + return nil +} + +func VerifyKeyID(signatureBytes []byte, expectedKeyID string) error { + if err := rejectDuplicateJSONKeys(signatureBytes); err != nil { + return err + } + var signature Signature + dec := json.NewDecoder(strings.NewReader(string(signatureBytes))) + dec.DisallowUnknownFields() + if err := dec.Decode(&signature); err != nil { + return fmt.Errorf("decode signature: %w", err) + } + var trailing any + if err := dec.Decode(&trailing); err != io.EOF { + return errors.New("signature contains trailing JSON") + } + if strings.TrimSpace(expectedKeyID) == "" || signature.KeyID != expectedKeyID { + return errors.New("signature key_id does not match manifest publisher") + } + return nil +} + +// rejectDuplicateJSONKeys performs a token-level walk because encoding/json +// otherwise accepts duplicate object members and silently keeps the last one. +func rejectDuplicateJSONKeys(raw []byte) error { + dec := json.NewDecoder(bytes.NewReader(raw)) + var walk func() error + walk = func() error { + tok, err := dec.Token() + if err != nil { + return err + } + delim, ok := tok.(json.Delim) + if !ok { + return nil + } + switch delim { + case '{': + seen := map[string]struct{}{} + for dec.More() { + key, err := dec.Token() + if err != nil { + return err + } + name, ok := key.(string) + if !ok { + return errors.New("object member name must be a string") + } + if _, exists := seen[name]; exists { + return fmt.Errorf("duplicate JSON object key: %s", name) + } + seen[name] = struct{}{} + if err := walk(); err != nil { + return err + } + } + end, err := dec.Token() + if err != nil { + return err + } + if end != json.Delim('}') { + return errors.New("invalid JSON object") + } + case '[': + for dec.More() { + if err := walk(); err != nil { + return err + } + } + end, err := dec.Token() + if err != nil { + return err + } + if end != json.Delim(']') { + return errors.New("invalid JSON array") + } + } + return nil + } + if err := walk(); err != nil { + return fmt.Errorf("invalid JSON: %w", err) + } + var trailing any + if err := dec.Decode(&trailing); err != io.EOF { + if err == nil { + return errors.New("JSON contains trailing data") + } + return fmt.Errorf("invalid JSON trailing data: %w", err) + } + return nil +} + +func RequiredAllowlist() []string { return append([]string(nil), requiredAllowlist...) } + +func (m Manifest) SortedCapabilities() []string { + out := append([]string(nil), m.Capabilities...) + sort.Strings(out) + return out +} + +// EvaluateCompatibility applies the V1 rule that an untested Core is compatible +// but must remain disabled until an administrator explicitly accepts it. +func (m Manifest) EvaluateCompatibility(coreVersion string) Compatibility { + coreVersion = strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(coreVersion), "sub2api-"), "v") + baseline := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(m.CoreAPIBaseline), "sub2api-"), "v") + if coreVersion == "" || baseline == "" || coreVersion != baseline { + return Compatibility{Status: "incompatible", Message: "Core version does not match plugin baseline"} + } + tested := false + for _, version := range m.TestedCoreVersions { + v := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(version), "sub2api-"), "v") + if v == coreVersion { + tested = true + break + } + } + if !tested { + return Compatibility{Compatible: true, Status: "untested", Message: "Core version is compatible but not tested"} + } + return Compatibility{Compatible: true, Tested: true, Status: "compatible", Message: "Core version is tested"} +} diff --git a/plugins/plugin-admin/internal/manifest/manifest_test.go b/plugins/plugin-admin/internal/manifest/manifest_test.go new file mode 100644 index 0000000..432843d --- /dev/null +++ b/plugins/plugin-admin/internal/manifest/manifest_test.go @@ -0,0 +1,119 @@ +package manifest + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func validManifest() Manifest { + return Manifest{ + SchemaVersion: 1, PluginID: "qiu.plugin-admin", Name: "Plugin Admin", Version: "1.0.0", + CoreAPIBaseline: "sub2api-0.1.183", Capabilities: []string{"plugin.admin.v1", "diagnostics.v1"}, + TestedCoreVersions: []string{"0.1.183"}, + Backend: Backend{HealthPath: "/healthz", ReadinessPath: "/readyz", ListenEnv: "PLUGIN_PORT"}, + UI: UI{Entrypoint: "/admin/", Menu: Menu{ID: "qiu.plugin-admin", Label: "Plugin Admin", Visibility: "admin", SortOrder: 200}}, + Publisher: Publisher{KeyID: "publisher-key"}, CoreAPIAllowlist: RequiredAllowlist(), + } +} + +func TestValidateManifestAndRejectsUnsafeEntries(t *testing.T) { + m := validManifest() + if err := Validate(m); err != nil { + t.Fatal(err) + } + for name, mutate := range map[string]func(*Manifest){ + "duplicate allowlist": func(m *Manifest) { m.CoreAPIAllowlist = append(m.CoreAPIAllowlist, m.CoreAPIAllowlist[0]) }, + "wildcard": func(m *Manifest) { m.CoreAPIAllowlist = append(m.CoreAPIAllowlist, "GET /api/v1/admin/*") }, + "menu mismatch": func(m *Manifest) { m.UI.Menu.ID = "other.plugin" }, + "traversal": func(m *Manifest) { m.UI.Entrypoint = "/admin/../secret" }, + "entrypoint URL": func(m *Manifest) { m.UI.Entrypoint = "https://example.invalid/ui.js" }, + "file drive path": func(m *Manifest) { m.Files = map[string]string{"C:/plugin.js": strings.Repeat("a", 64)} }, + "file traversal": func(m *Manifest) { m.Files = map[string]string{"ui/../plugin.js": strings.Repeat("a", 64)} }, + } { + t.Run(name, func(t *testing.T) { + candidate := m + mutate(&candidate) + if err := Validate(candidate); err == nil { + t.Fatal("expected validation error") + } + }) + } +} + +func TestLoadRejectsUnknownAndTrailingJSON(t *testing.T) { + dir := t.TempDir() + for name, raw := range map[string]string{ + "unknown": `{"schema_version":1,"extra":true}`, + "trailing": `{"schema_version":1} {}`, + "duplicate": `{"schema_version":1,"schema_version":1}`, + } { + path := filepath.Join(dir, name+".json") + if err := os.WriteFile(path, []byte(raw), 0o600); err != nil { + t.Fatal(err) + } + if _, _, err := Load(path); err == nil { + t.Fatalf("%s: expected error", name) + } + } +} + +func TestLoadRejectsNestedDuplicateJSONKeys(t *testing.T) { + dir := t.TempDir() + raw := `{"schema_version":1,"backend":{"health_path":"/healthz","health_path":"/readyz"}}` + path := filepath.Join(dir, "nested-duplicate.json") + if err := os.WriteFile(path, []byte(raw), 0o600); err != nil { + t.Fatal(err) + } + if _, _, err := Load(path); err == nil { + t.Fatal("expected nested duplicate key error") + } +} + +func TestSignatureAndKeyID(t *testing.T) { + publicKey, privateKey, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + manifestBytes := []byte(`{"schema_version":1}`) + signature := Signature{Algorithm: "ed25519", KeyID: "publisher-key", Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, manifestBytes))} + signatureBytes, err := json.Marshal(signature) + if err != nil { + t.Fatal(err) + } + publicKeyBytes := []byte(base64.StdEncoding.EncodeToString(publicKey)) + if err := VerifyKeyID(signatureBytes, "publisher-key"); err != nil { + t.Fatal(err) + } + if err := VerifySignature(manifestBytes, signatureBytes, publicKeyBytes); err != nil { + t.Fatal(err) + } + if err := VerifySignature([]byte(`{"schema_version":2}`), signatureBytes, publicKeyBytes); err == nil { + t.Fatal("expected tamper failure") + } + if err := VerifyKeyID([]byte(strings.TrimSuffix(string(signatureBytes), "}")+"}{}"), "publisher-key"); err == nil { + t.Fatal("expected trailing signature failure") + } + duplicate := []byte(`{"algorithm":"ed25519","algorithm":"ed25519","key_id":"publisher-key","signature":""}`) + if err := VerifyKeyID(duplicate, "publisher-key"); err == nil { + t.Fatal("expected duplicate signature key failure") + } +} + +func TestCompatibility(t *testing.T) { + m := validManifest() + if got := m.EvaluateCompatibility("sub2api-0.1.183"); !got.Compatible || !got.Tested || got.Status != "compatible" { + t.Fatalf("got %#v", got) + } + m.TestedCoreVersions = nil + if got := m.EvaluateCompatibility("0.1.183"); !got.Compatible || got.Tested || got.Status != "untested" { + t.Fatalf("got %#v", got) + } + if got := m.EvaluateCompatibility("0.1.184"); got.Compatible || got.Status != "incompatible" { + t.Fatalf("got %#v", got) + } +} diff --git a/plugins/plugin-admin/main.go b/plugins/plugin-admin/main.go new file mode 100644 index 0000000..f4e5bd5 --- /dev/null +++ b/plugins/plugin-admin/main.go @@ -0,0 +1,2710 @@ +// Command plugin-admin is the standalone Business Plugin V1 control plane. +// It deliberately lives outside Sub2API Core: Core remains the authority for +// identity and business data while this service owns plugin packages, +// revisions, lifecycle state, configuration metadata and menu intents. +package main + +import ( + "archive/zip" + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "embed" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "os/signal" + "path" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "git.awaioi.com/awaioi/sub2api-add/plugins/plugin-admin/internal/manifest" +) + +const ( + pluginID = "qiu.plugin-admin" + pluginVersion = "1.0.0" + sessionCookieName = "plugin_admin_session" + maxJSONBytes = 2 << 20 + maxPackageBytes = 128 << 20 + maxPackageFiles = 512 + maxUncompressedBytes = 256 << 20 + defaultSessionTTL = 30 * time.Minute + defaultSessionMaxTTL = 8 * time.Hour + defaultOperationLimit = 200 + maxOperationBodyBytes = maxPackageBytes + (4 << 20) +) + +//go:embed ui/* +var uiFS embed.FS + +var requestIDPattern = regexp.MustCompile(`^[A-Za-z0-9._:-]{1,64}$`) + +type coreEnvelope struct { + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` + status int +} + +type coreError struct{ status int } + +func (e *coreError) Error() string { return "core request failed" } + +type coreClient struct { + base string + http *http.Client + admin map[string]struct{} + mu sync.Mutex +} + +func newCoreClient(raw string) (*coreClient, error) { + raw = strings.TrimRight(strings.TrimSpace(raw), "/") + u, err := url.Parse(raw) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") { + return nil, errors.New("CORE_BASE_URL must be an absolute origin without credentials, path, query or fragment") + } + if u.Scheme == "http" && !isLoopbackHost(u.Hostname()) { + return nil, errors.New("CORE_BASE_URL must use HTTPS unless Core is on loopback") + } + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = nil + return &coreClient{base: raw, http: &http.Client{Timeout: 10 * time.Second, Transport: transport, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}}, nil +} + +func isLoopbackHost(host string) bool { + if strings.EqualFold(strings.TrimSuffix(host, "."), "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +func (c *coreClient) call(ctx context.Context, method, requestPath string, body any, accessToken, requestID string) (coreEnvelope, error) { + if c == nil { + return coreEnvelope{}, errors.New("core client unavailable") + } + if !allowedCorePath(method, requestPath) { + return coreEnvelope{}, errors.New("core path is not in the control-plane allowlist") + } + var reader io.Reader + if body != nil { + payload, err := json.Marshal(body) + if err != nil { + return coreEnvelope{}, err + } + reader = bytes.NewReader(payload) + } + req, err := http.NewRequestWithContext(ctx, method, c.base+requestPath, reader) + if err != nil { + return coreEnvelope{}, err + } + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if accessToken != "" { + req.Header.Set("Authorization", "Bearer "+accessToken) + } + requestID = normalizeRequestID(requestID) + req.Header.Set("X-Request-Id", requestID) + res, err := c.http.Do(req) + if err != nil { + return coreEnvelope{}, err + } + defer res.Body.Close() + var out coreEnvelope + if err := json.NewDecoder(io.LimitReader(res.Body, 4<<20)).Decode(&out); err != nil { + return coreEnvelope{}, err + } + out.status = res.StatusCode + if res.StatusCode >= 300 || out.Code != 0 { + status := res.StatusCode + if status < 400 && out.Code >= 400 { + status = out.Code + } + return out, &coreError{status: status} + } + return out, nil +} + +func allowedCorePath(method, p string) bool { + if method == http.MethodGet && (p == "/api/v1/auth/me" || p == "/api/v1/settings/public" || p == "/api/v1/admin/settings") { + return true + } + return method == http.MethodPut && p == "/api/v1/admin/settings" || method == http.MethodPost && (p == "/api/v1/auth/login" || p == "/api/v1/auth/login/2fa" || p == "/api/v1/auth/refresh" || p == "/api/v1/auth/logout") +} + +func (c *coreClient) login(ctx context.Context, body any, rid string) (coreEnvelope, error) { + return c.call(ctx, http.MethodPost, "/api/v1/auth/login", body, "", rid) +} +func (c *coreClient) login2FA(ctx context.Context, body any, rid string) (coreEnvelope, error) { + return c.call(ctx, http.MethodPost, "/api/v1/auth/login/2fa", body, "", rid) +} +func (c *coreClient) refresh(ctx context.Context, refresh, rid string) (coreEnvelope, error) { + return c.call(ctx, http.MethodPost, "/api/v1/auth/refresh", map[string]string{"refresh_token": refresh}, "", rid) +} +func (c *coreClient) logout(ctx context.Context, refresh, rid string) { + if refresh != "" { + _, _ = c.call(ctx, http.MethodPost, "/api/v1/auth/logout", map[string]string{"refresh_token": refresh}, "", rid) + } +} +func (c *coreClient) me(ctx context.Context, access, rid string) (coreEnvelope, error) { + return c.call(ctx, http.MethodGet, "/api/v1/auth/me", nil, access, rid) +} +func (c *coreClient) settings(ctx context.Context, access, rid string) (coreEnvelope, error) { + return c.call(ctx, http.MethodGet, "/api/v1/admin/settings", nil, access, rid) +} +func (c *coreClient) publicSettings(ctx context.Context, rid string) (coreEnvelope, error) { + return c.call(ctx, http.MethodGet, "/api/v1/settings/public", nil, "", rid) +} +func (c *coreClient) updateMenu(ctx context.Context, access, rid string, items []any) (coreEnvelope, error) { + return c.call(ctx, http.MethodPut, "/api/v1/admin/settings", map[string]any{"custom_menu_items": items}, access, rid) +} + +type session struct { + AccessToken string `json:"-"` + RefreshToken string `json:"-"` + CSRFToken string `json:"csrf_token"` + User map[string]any `json:"user"` + CreatedAt time.Time `json:"created_at"` + LastSeen time.Time `json:"last_seen"` +} + +type pendingLogin struct { + TempToken string + Expires time.Time +} + +type revision struct { + ID string `json:"id"` + Version string `json:"version"` + Path string `json:"path"` + ArchiveSHA string `json:"archive_sha256"` + Manifest manifest.Manifest `json:"manifest"` + VerifiedAt time.Time `json:"verified_at"` + HealthyAt time.Time `json:"healthy_at,omitempty"` + Retired bool `json:"retired,omitempty"` +} + +type pluginRecord struct { + Manifest manifest.Manifest `json:"manifest"` + State string `json:"state"` + ActiveRevision string `json:"active_revision,omitempty"` + PendingRevision string `json:"pending_revision,omitempty"` + PreviousState string `json:"previous_state,omitempty"` + Revisions []revision `json:"revisions"` + ConfigCipher string `json:"config_cipher,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + LastError string `json:"last_error,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +type operation struct { + ID string `json:"id"` + IdempotencyKey string `json:"idempotency_key"` + Kind string `json:"kind"` + PluginID string `json:"plugin_id"` + Revision string `json:"revision,omitempty"` + ActorID any `json:"actor_id,omitempty"` + RequestID string `json:"request_id"` + RequestHash string `json:"request_hash,omitempty"` + State string `json:"state"` + Error string `json:"error,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type auditEvent struct { + Time time.Time `json:"time"` + Action string `json:"action"` + PluginID string `json:"plugin_id,omitempty"` + ActorID any `json:"actor_id,omitempty"` + Operation string `json:"operation_id,omitempty"` + RequestID string `json:"request_id"` + Result string `json:"result"` +} + +// stagedRevisionPath keeps an uninstall reversible until the registry commit +// has succeeded. Revision directories are renamed within the installed root, +// then removed after the registry no longer references them. +type stagedRevisionPath struct { + original string + staged string +} + +type registryData struct { + Plugins map[string]pluginRecord `json:"plugins"` + Operations []operation `json:"operations"` + Audit []auditEvent `json:"audit"` +} + +func clonePluginRecord(p pluginRecord) pluginRecord { + p.Revisions = append([]revision(nil), p.Revisions...) + for i := range p.Revisions { + p.Revisions[i].Manifest.Capabilities = append([]string(nil), p.Revisions[i].Manifest.Capabilities...) + p.Revisions[i].Manifest.TestedCoreVersions = append([]string(nil), p.Revisions[i].Manifest.TestedCoreVersions...) + if p.Revisions[i].Manifest.Files != nil { + p.Revisions[i].Manifest.Files = make(map[string]string, len(p.Revisions[i].Manifest.Files)) + for key, value := range p.Revisions[i].Manifest.Files { + p.Revisions[i].Manifest.Files[key] = value + } + } + } + p.Manifest.Capabilities = append([]string(nil), p.Manifest.Capabilities...) + p.Manifest.TestedCoreVersions = append([]string(nil), p.Manifest.TestedCoreVersions...) + if p.Manifest.Files != nil { + p.Manifest.Files = make(map[string]string, len(p.Manifest.Files)) + for key, value := range p.Manifest.Files { + p.Manifest.Files[key] = value + } + } + return p +} + +type registry struct { + path string + mu sync.Mutex + data registryData +} + +func openRegistry(dir string) (*registry, error) { + if dir == "" { + dir = "./data" + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, err + } + r := ®istry{path: filepath.Join(dir, "registry.json"), data: registryData{Plugins: map[string]pluginRecord{}, Operations: []operation{}, Audit: []auditEvent{}}} + raw, err := os.ReadFile(r.path) + if errors.Is(err, os.ErrNotExist) { + return r, nil + } + if err != nil { + return nil, err + } + if err := json.Unmarshal(raw, &r.data); err != nil { + return nil, fmt.Errorf("decode registry: %w", err) + } + if r.data.Plugins == nil { + r.data.Plugins = map[string]pluginRecord{} + } + if r.data.Operations == nil { + r.data.Operations = []operation{} + } + if r.data.Audit == nil { + r.data.Audit = []auditEvent{} + } + changed := false + for i := range r.data.Operations { + if r.data.Operations[i].State == "running" && time.Since(r.data.Operations[i].UpdatedAt) > 10*time.Minute { + r.data.Operations[i].State = "failed" + r.data.Operations[i].Error = "operation interrupted by control-plane restart" + r.data.Operations[i].UpdatedAt = time.Now().UTC() + changed = true + } + } + if changed { + if err := r.saveLocked(); err != nil { + return nil, err + } + } + return r, nil +} + +func (r *registry) saveLocked() error { + raw, err := json.MarshalIndent(r.data, "", " ") + if err != nil { + return err + } + tmp := r.path + ".tmp-" + token(8) + if err := os.WriteFile(tmp, raw, 0o600); err != nil { + return err + } + if err := os.Rename(tmp, r.path); err != nil { + _ = os.Remove(tmp) + return err + } + return nil +} + +func (r *registry) save() error { r.mu.Lock(); defer r.mu.Unlock(); return r.saveLocked() } + +func (r *registry) addAudit(ev auditEvent) error { + r.mu.Lock() + defer r.mu.Unlock() + previous := append([]auditEvent(nil), r.data.Audit...) + r.data.Audit = append(r.data.Audit, ev) + if len(r.data.Audit) > 500 { + r.data.Audit = r.data.Audit[len(r.data.Audit)-500:] + } + if err := r.saveLocked(); err != nil { + // Do not report an audit event that was not persisted. + r.data.Audit = previous + return err + } + return nil +} + +func (r *registry) operation(kind, pluginID, key string, actor any, rid, requestHash string) (operation, bool, bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + for i := len(r.data.Operations) - 1; i >= 0; i-- { + op := r.data.Operations[i] + if op.Kind == kind && op.PluginID == pluginID && key != "" && op.IdempotencyKey == key && fmt.Sprint(op.ActorID) == fmt.Sprint(actor) { + return op, true, op.RequestHash != "" && requestHash != "" && op.RequestHash != requestHash, nil + } + } + previous := append([]operation(nil), r.data.Operations...) + op := operation{ID: token(16), IdempotencyKey: key, Kind: kind, PluginID: pluginID, ActorID: actor, RequestID: rid, RequestHash: requestHash, State: "running", CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()} + r.data.Operations = append(r.data.Operations, op) + if len(r.data.Operations) > defaultOperationLimit { + r.data.Operations = r.data.Operations[len(r.data.Operations)-defaultOperationLimit:] + } + if err := r.saveLocked(); err != nil { + r.data.Operations = previous + return operation{}, false, false, err + } + return op, false, false, nil +} + +func (r *registry) finishOperation(op operation, operationErr error) (operation, error) { + r.mu.Lock() + defer r.mu.Unlock() + previous := operation{} + found := false + for i := range r.data.Operations { + if r.data.Operations[i].ID == op.ID { + previous = r.data.Operations[i] + found = true + break + } + } + if !found { + return op, errors.New("operation not found") + } + op.State = "completed" + if operationErr != nil { + op.State = "failed" + op.Error = sanitizeError(operationErr) + } + op.UpdatedAt = time.Now().UTC() + for i := range r.data.Operations { + if r.data.Operations[i].ID == op.ID { + r.data.Operations[i] = op + break + } + } + if err := r.saveLocked(); err != nil { + if found { + for i := range r.data.Operations { + if r.data.Operations[i].ID == op.ID { + r.data.Operations[i] = previous + break + } + } + } else if len(r.data.Operations) > 0 { + r.data.Operations = r.data.Operations[:len(r.data.Operations)-1] + } + return op, err + } + return op, nil +} + +func (r *registry) setOperationPlugin(operationID, pluginID string) error { + r.mu.Lock() + defer r.mu.Unlock() + for i := range r.data.Operations { + if r.data.Operations[i].ID != operationID { + continue + } + previous := r.data.Operations[i] + r.data.Operations[i].PluginID = pluginID + if err := r.saveLocked(); err != nil { + r.data.Operations[i] = previous + return err + } + return nil + } + return errors.New("operation not found") +} + +func sanitizeError(err error) string { + if err == nil { + return "" + } + message := strings.TrimSpace(err.Error()) + for _, secret := range []string{"Bearer ", "refresh_token", "password", "api_key", "secret"} { + if strings.Contains(strings.ToLower(message), strings.ToLower(secret)) { + return "operation failed" + } + } + if len(message) > 240 { + return message[:240] + } + return message +} + +type app struct { + core *coreClient + registry *registry + root string + publicBasePath string + cookiePath string + cookieSecure bool + cookieSameSite http.SameSite + frameAncestors []string + allowUnsigned bool + trustedPublishers map[string][]byte + configKey []byte + sessions map[string]session + sessionLocks map[string]*sync.Mutex + pending map[string]pendingLogin + processes map[string]*exec.Cmd + pluginLocks map[string]*sync.Mutex + mu sync.Mutex +} + +func newApp(core *coreClient, r *registry, root string) *app { + key := sha256.Sum256([]byte(token(32))) + return &app{core: core, registry: r, root: root, cookiePath: "/", cookieSameSite: http.SameSiteLaxMode, frameAncestors: []string{"'self'"}, configKey: key[:], sessions: map[string]session{}, sessionLocks: map[string]*sync.Mutex{}, pending: map[string]pendingLogin{}, processes: map[string]*exec.Cmd{}, pluginLocks: map[string]*sync.Mutex{}} +} + +func (a *app) lockPlugin(id string) func() { + a.mu.Lock() + lock := a.pluginLocks[id] + if lock == nil { + lock = &sync.Mutex{} + a.pluginLocks[id] = lock + } + a.mu.Unlock() + lock.Lock() + return lock.Unlock +} + +func requestID(r *http.Request) string { + if r == nil { + return token(12) + } + value := strings.TrimSpace(r.Header.Get("X-Request-Id")) + if requestIDPattern.MatchString(value) { + return value + } + return token(12) +} + +func normalizeRequestID(value string) string { + value = strings.TrimSpace(value) + if requestIDPattern.MatchString(value) { + return value + } + return token(12) +} + +func token(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return hex.EncodeToString(b) +} + +func decodeJSON(r *http.Request, dst any, limit int64) error { + if r == nil || r.Body == nil { + return errors.New("request body is required") + } + defer r.Body.Close() + raw, err := io.ReadAll(io.LimitReader(r.Body, limit+1)) + if err != nil { + return err + } + if int64(len(raw)) > limit { + return errors.New("request body exceeds size limit") + } + if len(bytes.TrimSpace(raw)) == 0 { + return errors.New("request body is required") + } + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(dst); err != nil { + return err + } + var trailing any + if err := dec.Decode(&trailing); err != io.EOF { + if err == nil { + return errors.New("request body must contain exactly one JSON value") + } + return err + } + return nil +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func (a *app) setCookie(w http.ResponseWriter, id string, maxAge int) { + http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: id, Path: a.cookiePath, HttpOnly: true, Secure: a.cookieSecure, SameSite: a.cookieSameSite, MaxAge: maxAge}) +} + +func (a *app) sessionFromRequest(r *http.Request) (string, session, bool) { + c, err := r.Cookie(sessionCookieName) + if err != nil || c.Value == "" { + return "", session{}, false + } + a.mu.Lock() + defer a.mu.Unlock() + s, ok := a.sessions[c.Value] + if !ok { + return "", session{}, false + } + now := time.Now() + if now.Sub(s.CreatedAt) > defaultSessionMaxTTL || now.Sub(s.LastSeen) > defaultSessionTTL { + delete(a.sessions, c.Value) + delete(a.sessionLocks, c.Value) + return "", session{}, false + } + s.LastSeen = now + a.sessions[c.Value] = s + return c.Value, s, true +} + +func publicUser(user map[string]any) map[string]any { + out := map[string]any{} + for key, value := range user { + lower := strings.ToLower(key) + if strings.Contains(lower, "token") || strings.Contains(lower, "password") || strings.Contains(lower, "secret") || lower == "api_key" || lower == "refresh_token" { + continue + } + out[key] = value + } + return out +} + +func envelopeData(e coreEnvelope) map[string]any { + var data map[string]any + if len(e.Data) > 0 && json.Unmarshal(e.Data, &data) == nil && data != nil { + return data + } + return map[string]any{} +} + +func menuItemsFromSettings(data map[string]any) []any { + if raw, ok := data["custom_menu_items"].([]any); ok { + return raw + } + if encoded, ok := data["custom_menu_items"].(string); ok && strings.TrimSpace(encoded) != "" { + var items []any + if json.Unmarshal([]byte(encoded), &items) == nil { + return items + } + } + return nil +} + +func isAdmin(user map[string]any) bool { + role, _ := user["role"].(string) + return strings.EqualFold(role, "admin") || strings.EqualFold(role, "administrator") +} + +func (a *app) login(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + var in struct { + Email string `json:"email"` + Password string `json:"password"` + TurnstileToken string `json:"turnstile_token,omitempty"` + TencentCaptchaTicket string `json:"tencent_captcha_ticket,omitempty"` + TencentCaptchaRandstr string `json:"tencent_captcha_randstr,omitempty"` + } + if err := decodeJSON(r, &in, 1<<20); err != nil || strings.TrimSpace(in.Email) == "" || in.Password == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid credentials"}) + return + } + if a.core == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "core unavailable"}) + return + } + out, err := a.core.login(r.Context(), in, requestID(r)) + if err != nil { + a.coreError(w, err, "core login failed") + return + } + data := envelopeData(out) + if requires, _ := data["requires_2fa"].(bool); requires { + temp, _ := data["temp_token"].(string) + if temp == "" { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "2fa challenge missing"}) + return + } + pending := token(16) + a.mu.Lock() + a.pending[pending] = pendingLogin{TempToken: temp, Expires: time.Now().Add(5 * time.Minute)} + a.mu.Unlock() + writeJSON(w, http.StatusOK, map[string]any{"requires_2fa": true, "pending_token": pending}) + return + } + a.finishLogin(w, r, data) +} + +func (a *app) login2FA(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + var in struct { + PendingToken string `json:"pending_token"` + TOTPCode string `json:"totp_code"` + } + if err := decodeJSON(r, &in, 1<<20); err != nil || in.PendingToken == "" || len(in.TOTPCode) != 6 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid 2fa request"}) + return + } + a.mu.Lock() + pending, ok := a.pending[in.PendingToken] + delete(a.pending, in.PendingToken) + a.mu.Unlock() + if !ok || time.Now().After(pending.Expires) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "2fa session expired"}) + return + } + out, err := a.core.login2FA(r.Context(), map[string]string{"temp_token": pending.TempToken, "totp_code": in.TOTPCode}, requestID(r)) + if err != nil { + a.coreError(w, err, "2fa verification failed") + return + } + a.finishLogin(w, r, envelopeData(out)) +} + +func (a *app) finishLogin(w http.ResponseWriter, r *http.Request, data map[string]any) { + access, _ := data["access_token"].(string) + refresh, _ := data["refresh_token"].(string) + if access == "" { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "core token missing"}) + return + } + me, err := a.core.me(r.Context(), access, requestID(r)) + if err != nil { + a.core.logout(r.Context(), refresh, requestID(r)) + writeJSON(w, http.StatusForbidden, map[string]string{"error": "admin verification failed"}) + return + } + user := envelopeData(me) + if !isAdmin(user) { + a.core.logout(r.Context(), refresh, requestID(r)) + writeJSON(w, http.StatusForbidden, map[string]string{"error": "admin role required"}) + return + } + now := time.Now() + s := session{AccessToken: access, RefreshToken: refresh, CSRFToken: token(16), User: publicUser(user), CreatedAt: now, LastSeen: now} + id := token(32) + a.mu.Lock() + a.sessions[id] = s + a.sessionLocks[id] = &sync.Mutex{} + a.mu.Unlock() + a.setCookie(w, id, int(defaultSessionMaxTTL/time.Second)) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "csrf_token": s.CSRFToken, "user": s.User}) +} + +func (a *app) removeSession(id string) { + a.mu.Lock() + delete(a.sessions, id) + delete(a.sessionLocks, id) + a.mu.Unlock() +} + +func (a *app) logout(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + if id, s, ok := a.sessionFromRequest(r); ok { + if r.Header.Get("X-CSRF-Token") != s.CSRFToken { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "csrf validation failed"}) + return + } + a.removeSession(id) + a.core.logout(r.Context(), s.RefreshToken, requestID(r)) + } + a.setCookie(w, "", -1) + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +func (a *app) refreshSession(ctx context.Context, id string, stale session) (session, bool) { + a.mu.Lock() + lock := a.sessionLocks[id] + a.mu.Unlock() + if lock == nil { + return session{}, false + } + lock.Lock() + defer lock.Unlock() + a.mu.Lock() + current, ok := a.sessions[id] + a.mu.Unlock() + if !ok { + return session{}, false + } + if current.AccessToken != stale.AccessToken { + return current, true + } + out, err := a.core.refresh(ctx, current.RefreshToken, token(12)) + if err != nil { + return session{}, false + } + data := envelopeData(out) + access, _ := data["access_token"].(string) + if access == "" { + return session{}, false + } + current.AccessToken = access + if next, _ := data["refresh_token"].(string); next != "" { + current.RefreshToken = next + } + me, err := a.core.me(ctx, access, token(12)) + if err != nil { + return session{}, false + } + current.User = publicUser(envelopeData(me)) + if !isAdmin(envelopeData(me)) { + return current, false + } + current.LastSeen = time.Now() + a.mu.Lock() + a.sessions[id] = current + a.mu.Unlock() + return current, true +} + +func (a *app) authenticate(w http.ResponseWriter, r *http.Request) (string, session, bool) { + id, s, ok := a.sessionFromRequest(r) + if !ok { + a.setCookie(w, "", -1) + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"}) + return "", session{}, false + } + if r.Method != http.MethodGet && r.Header.Get("X-CSRF-Token") != s.CSRFToken { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "csrf validation failed"}) + return "", session{}, false + } + if a.core == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "core unavailable"}) + return "", session{}, false + } + me, err := a.core.me(r.Context(), s.AccessToken, requestID(r)) + if err != nil { + if ce, ok := err.(*coreError); ok && ce.status == http.StatusUnauthorized && s.RefreshToken != "" { + if next, refreshed := a.refreshSession(r.Context(), id, s); refreshed { + return id, next, true + } else if next.User != nil && !isAdmin(next.User) { + a.removeSession(id) + a.core.logout(r.Context(), s.RefreshToken, requestID(r)) + writeJSON(w, http.StatusForbidden, map[string]string{"error": "admin role required"}) + return "", session{}, false + } + } + a.removeSession(id) + a.core.logout(r.Context(), s.RefreshToken, requestID(r)) + a.setCookie(w, "", -1) + if ce, ok := err.(*coreError); ok && ce.status == http.StatusUnauthorized { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "core session expired"}) + } else { + a.coreError(w, err, "core session unavailable") + } + return "", session{}, false + } + user := envelopeData(me) + if !isAdmin(user) { + a.removeSession(id) + a.core.logout(r.Context(), s.RefreshToken, requestID(r)) + writeJSON(w, http.StatusForbidden, map[string]string{"error": "admin role required"}) + return "", session{}, false + } + s.User = publicUser(user) + a.mu.Lock() + a.sessions[id] = s + a.mu.Unlock() + return id, s, true +} + +func (a *app) me(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + _, s, ok := a.authenticate(w, r) + if ok { + writeJSON(w, http.StatusOK, map[string]any{"user": s.User, "csrf_token": s.CSRFToken, "plugin_id": pluginID, "plugin_version": pluginVersion}) + } +} + +func (a *app) health(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "plugin_id": pluginID, "version": pluginVersion}) +} + +func (a *app) ready(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + if a.registry == nil || a.core == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "not_ready"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"status": "ready", "plugin_id": pluginID, "version": pluginVersion}) +} + +func (a *app) apiPlugins(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + if _, _, ok := a.authenticate(w, r); !ok { + return + } + a.registry.mu.Lock() + items := make([]map[string]any, 0, len(a.registry.data.Plugins)) + for _, p := range a.registry.data.Plugins { + items = append(items, a.publicPlugin(p)) + } + a.registry.mu.Unlock() + sort.Slice(items, func(i, j int) bool { return items[i]["plugin_id"].(string) < items[j]["plugin_id"].(string) }) + writeJSON(w, http.StatusOK, map[string]any{"items": items}) +} + +func (a *app) operationByID(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + if _, _, ok := a.authenticate(w, r); !ok { + return + } + id := strings.TrimPrefix(r.URL.Path, "/api/operations/") + if id == "" || strings.ContainsAny(id, "/\\") { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid operation id"}) + return + } + a.registry.mu.Lock() + defer a.registry.mu.Unlock() + for _, op := range a.registry.data.Operations { + if op.ID == id { + writeJSON(w, http.StatusOK, op) + return + } + } + writeJSON(w, http.StatusNotFound, map[string]string{"error": "operation not found"}) +} + +func (a *app) publicPlugin(p pluginRecord) map[string]any { + revisions := make([]map[string]any, 0, len(p.Revisions)) + for _, rev := range p.Revisions { + revisions = append(revisions, map[string]any{"id": rev.ID, "version": rev.Version, "archive_sha256": rev.ArchiveSHA, "verified_at": rev.VerifiedAt, "healthy_at": rev.HealthyAt}) + } + compat := p.Manifest.EvaluateCompatibility(a.currentCoreVersion(context.Background())) + return map[string]any{"plugin_id": p.Manifest.PluginID, "name": p.Manifest.Name, "version": p.Manifest.Version, "capabilities": p.Manifest.SortedCapabilities(), "state": p.State, "active_revision": p.ActiveRevision, "pending_revision": p.PendingRevision, "revisions": revisions, "compatibility": compat, "endpoint": p.Endpoint, "last_error": p.LastError, "updated_at": p.UpdatedAt, "menu": p.Manifest.UI.Menu} +} + +func (a *app) currentCoreVersion(ctx context.Context) string { + if value := strings.TrimSpace(os.Getenv("CORE_VERSION")); value != "" { + return value + } + if a.core == nil { + return "" + } + out, err := a.core.publicSettings(ctx, token(12)) + if err != nil { + return "" + } + data := envelopeData(out) + if value, ok := data["version"].(string); ok { + return strings.TrimSpace(value) + } + return "" +} + +func (a *app) pluginIDFromPath(r *http.Request) (string, string, bool) { + parts := strings.Split(strings.TrimPrefix(strings.Trim(r.URL.Path, "/"), "api/plugins/"), "/") + if len(parts) < 1 || parts[0] == "" || strings.ContainsAny(parts[0], "/\\") || strings.Contains(parts[0], "..") { + return "", "", false + } + action := "" + if len(parts) > 1 { + action = parts[1] + } + return parts[0], action, true +} + +func (a *app) getPlugin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + if _, _, ok := a.authenticate(w, r); !ok { + return + } + id, action, ok := a.pluginIDFromPath(r) + if !ok || action != "" { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "plugin not found"}) + return + } + a.registry.mu.Lock() + p, found := a.registry.data.Plugins[id] + a.registry.mu.Unlock() + if !found { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "plugin not found"}) + return + } + writeJSON(w, http.StatusOK, a.publicPlugin(p)) +} + +func (a *app) operationResponse(w http.ResponseWriter, op operation) { + writeJSON(w, http.StatusAccepted, map[string]any{"operation_id": op.ID, "state": op.State, "error": op.Error}) +} + +func (a *app) finalizeOperation(op operation, operationErr error, event auditEvent) (operation, error) { + finished, finishErr := a.registry.finishOperation(op, operationErr) + if finishErr != nil { + return finished, finishErr + } + event.Operation = finished.ID + event.Result = finished.State + auditErr := a.registry.addAudit(event) + if auditErr != nil { + return finished, auditErr + } + return finished, nil +} + +func writeOperationPersistenceError(w http.ResponseWriter, op operation) { + writeJSON(w, http.StatusInternalServerError, map[string]any{ + "operation_id": op.ID, + "state": op.State, + "error": "operation registry is unavailable", + }) +} + +func (a *app) mutationAuth(w http.ResponseWriter, r *http.Request, kind, plugin string) (operation, session, bool) { + body, err := captureRequestBody(r, maxOperationBodyBytes) + if err != nil { + writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "request body exceeds size limit"}) + return operation{}, session{}, false + } + return a.mutationAuthWithHash(w, r, kind, plugin, operationHashWithBody(r, body)) +} + +func (a *app) mutationAuthWithHash(w http.ResponseWriter, r *http.Request, kind, plugin, hash string) (operation, session, bool) { + _, s, ok := a.authenticate(w, r) + if !ok { + return operation{}, session{}, false + } + key := strings.TrimSpace(r.Header.Get("Idempotency-Key")) + if key == "" || len(key) > 128 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Idempotency-Key is required"}) + return operation{}, session{}, false + } + op, existing, conflict, err := a.registry.operation(kind, plugin, key, s.User["id"], requestID(r), hash) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "operation registry is unavailable"}) + return operation{}, session{}, false + } + if conflict { + writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key was already used for a different request"}) + return operation{}, session{}, false + } + if existing { + a.operationResponse(w, op) + return operation{}, session{}, false + } + return op, s, true +} + +func operationHash(r *http.Request) string { + return operationHashWithBody(r, nil) +} + +func operationHashWithBody(r *http.Request, body []byte) string { + if r == nil { + return "" + } + bodySum := sha256.Sum256(body) + value := r.Method + "\n" + r.URL.Path + "\n" + r.URL.RawQuery + "\n" + hex.EncodeToString(bodySum[:]) + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} + +func (a *app) stageRevisionPaths(revisions []revision) ([]stagedRevisionPath, error) { + base, err := filepath.Abs(filepath.Join(a.root, "installed")) + if err != nil { + return nil, err + } + moves := make([]stagedRevisionPath, 0, len(revisions)) + restore := func() { + for i := len(moves) - 1; i >= 0; i-- { + _ = os.Rename(moves[i].staged, moves[i].original) + } + } + for _, rev := range revisions { + if strings.TrimSpace(rev.Path) == "" { + continue + } + original, err := filepath.Abs(rev.Path) + if err != nil { + restore() + return nil, err + } + rel, err := filepath.Rel(base, original) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + restore() + return nil, errors.New("revision path is outside the plugin install root") + } + if _, err := os.Lstat(original); errors.Is(err, os.ErrNotExist) { + continue + } else if err != nil { + restore() + return nil, err + } + staged := original + ".uninstall-" + token(8) + if err := os.Rename(original, staged); err != nil { + restore() + return nil, err + } + moves = append(moves, stagedRevisionPath{original: original, staged: staged}) + } + return moves, nil +} + +func restoreRevisionPaths(moves []stagedRevisionPath) error { + var firstErr error + for i := len(moves) - 1; i >= 0; i-- { + if _, err := os.Lstat(moves[i].staged); errors.Is(err, os.ErrNotExist) { + continue + } + if err := os.Rename(moves[i].staged, moves[i].original); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +func removeStagedRevisionPaths(moves []stagedRevisionPath) error { + var firstErr error + for _, move := range moves { + if err := os.RemoveAll(move.staged); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +// captureRequestBody makes the server-side request fingerprint include the +// actual payload while restoring the body for the handler that still needs to +// decode it. The limit prevents an idempotency check from becoming an upload +// amplification vector. +func captureRequestBody(r *http.Request, limit int64) ([]byte, error) { + if r == nil || r.Body == nil { + return nil, nil + } + raw, err := io.ReadAll(io.LimitReader(r.Body, limit+1)) + _ = r.Body.Close() + r.Body = io.NopCloser(bytes.NewReader(raw)) + if err != nil { + return raw, err + } + if int64(len(raw)) > limit { + return raw, errors.New("request body exceeds size limit") + } + return raw, nil +} + +type packageInfo struct { + Manifest manifest.Manifest + Raw []byte + Archive []byte + Files map[string][]byte +} + +func (a *app) inspectPackage(raw []byte) (packageInfo, error) { + if len(raw) == 0 || len(raw) > maxPackageBytes { + return packageInfo{}, errors.New("package exceeds size limit") + } + hash := sha256.Sum256(raw) + _ = hash + zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) + if err != nil { + return packageInfo{}, errors.New("invalid plugin package") + } + if len(zr.File) > maxPackageFiles { + return packageInfo{}, errors.New("package contains too many files") + } + files := map[string][]byte{} + var total int64 + for _, file := range zr.File { + name := strings.ReplaceAll(file.Name, "\\", "/") + if file.FileInfo().IsDir() { + continue + } + if file.Mode()&os.ModeSymlink != 0 { + return packageInfo{}, fmt.Errorf("symlink entries are not allowed: %s", file.Name) + } + if name == "" || strings.HasPrefix(name, "/") || strings.Contains(name, "..") || path.Clean(name) != name || strings.Contains(name, "\\") { + return packageInfo{}, fmt.Errorf("unsafe package path: %s", file.Name) + } + if _, exists := files[name]; exists { + return packageInfo{}, fmt.Errorf("duplicate package path: %s", name) + } + if file.UncompressedSize64 > maxUncompressedBytes { + return packageInfo{}, errors.New("package member exceeds size limit") + } + rc, err := file.Open() + if err != nil { + return packageInfo{}, err + } + remaining := maxUncompressedBytes - total + if remaining <= 0 { + _ = rc.Close() + return packageInfo{}, errors.New("package exceeds uncompressed size limit") + } + data, readErr := io.ReadAll(io.LimitReader(rc, remaining+1)) + _ = rc.Close() + if readErr != nil { + return packageInfo{}, readErr + } + total += int64(len(data)) + if int64(len(data)) > remaining || total > maxUncompressedBytes { + return packageInfo{}, errors.New("package exceeds uncompressed size limit") + } + files[name] = data + } + manifestRaw, ok := files["manifest.json"] + if !ok { + return packageInfo{}, errors.New("manifest.json is required") + } + manifestPath := filepath.Join(os.TempDir(), "plugin-manifest-"+token(8)+".json") + defer os.Remove(manifestPath) + if err := os.WriteFile(manifestPath, manifestRaw, 0o600); err != nil { + return packageInfo{}, err + } + m, _, err := manifest.Load(manifestPath) + if err != nil { + return packageInfo{}, err + } + if signature, ok := files["signature.json"]; ok { + pub, trusted := a.trustedPublishers[m.Publisher.KeyID] + if !trusted { + return packageInfo{}, errors.New("plugin publisher is not trusted") + } + if err := manifest.VerifyKeyID(signature, m.Publisher.KeyID); err != nil { + return packageInfo{}, err + } + if err := manifest.VerifySignature(manifestRaw, signature, []byte(base64.StdEncoding.EncodeToString(pub))); err != nil { + return packageInfo{}, err + } + } else if !a.allowUnsigned { + return packageInfo{}, errors.New("signed plugin package is required") + } + for name, expected := range m.Files { + data, exists := files[name] + if !exists { + return packageInfo{}, fmt.Errorf("manifest file is missing: %s", name) + } + sum := sha256.Sum256(data) + if hex.EncodeToString(sum[:]) != expected { + return packageInfo{}, fmt.Errorf("file hash mismatch: %s", name) + } + } + for name := range files { + if name == "manifest.json" || name == "signature.json" { + continue + } + if _, declared := m.Files[name]; !declared { + return packageInfo{}, fmt.Errorf("package file is not declared in manifest: %s", name) + } + } + if len(m.Files) == 0 { + return packageInfo{}, errors.New("manifest.files must declare package files") + } + if _, ok := m.Files[m.UI.Entrypoint]; !ok { + entry := strings.Trim(strings.TrimSpace(m.UI.Entrypoint), "/") + if entry == "admin" || entry == "admin/index.html" { + if _, ok := m.Files["ui/index.html"]; !ok { + return packageInfo{}, errors.New("ui entrypoint requires ui/index.html") + } + } else { + return packageInfo{}, errors.New("ui.entrypoint is missing from files") + } + } + if m.Backend.Command != "" { + if _, ok := m.Files[m.Backend.Command]; !ok { + return packageInfo{}, errors.New("backend.command is missing from files") + } + } + return packageInfo{Manifest: m, Raw: manifestRaw, Archive: raw, Files: files}, nil +} + +func (a *app) installPackage(info packageInfo) (pluginRecord, error) { + unlock := a.lockPlugin(info.Manifest.PluginID) + defer unlock() + return a.installPackageMode(info, false) +} + +func (a *app) installPackageMode(info packageInfo, preserveActive bool) (pluginRecord, error) { + m := info.Manifest + compat := m.EvaluateCompatibility(a.currentCoreVersion(context.Background())) + state := "disabled" + if compat.Status == "incompatible" { + state = "incompatible" + } else if compat.Status == "untested" { + state = "disabled" + } + archiveHash := sha256.Sum256(info.Archive) + revisionID := time.Now().UTC().Format("20060102T150405.000000000Z") + "-" + hex.EncodeToString(archiveHash[:4]) + dir := filepath.Join(a.root, "installed", m.PluginID, revisionID) + staging := dir + ".staging-" + token(8) + if err := os.MkdirAll(staging, 0o700); err != nil { + return pluginRecord{}, err + } + committed := false + defer func() { + if !committed { + _ = os.RemoveAll(staging) + } + }() + for name, data := range info.Files { + dest := filepath.Join(staging, filepath.FromSlash(name)) + if !strings.HasPrefix(filepath.Clean(dest), filepath.Clean(staging)+string(os.PathSeparator)) { + return pluginRecord{}, errors.New("package path escaped staging directory") + } + if err := os.MkdirAll(filepath.Dir(dest), 0o700); err != nil { + return pluginRecord{}, err + } + mode := os.FileMode(0o600) + if strings.HasPrefix(name, "service/") { + mode = 0o700 + } + if err := os.WriteFile(dest, data, mode); err != nil { + return pluginRecord{}, err + } + } + if err := os.WriteFile(filepath.Join(staging, ".verified"), []byte(revisionID+"\n"), 0o600); err != nil { + return pluginRecord{}, err + } + if err := os.MkdirAll(filepath.Dir(dir), 0o700); err != nil { + return pluginRecord{}, err + } + if err := os.Rename(staging, dir); err != nil { + return pluginRecord{}, err + } + committed = true + p := pluginRecord{Manifest: m, State: state, ActiveRevision: revisionID, Revisions: []revision{{ID: revisionID, Version: m.Version, Path: dir, ArchiveSHA: hex.EncodeToString(archiveHash[:]), Manifest: m, VerifiedAt: time.Now().UTC()}}, UpdatedAt: time.Now().UTC()} + a.registry.mu.Lock() + old, existed := a.registry.data.Plugins[m.PluginID] + if existed { + old = clonePluginRecord(old) + } + if existed { + if !preserveActive { + a.registry.mu.Unlock() + _ = os.RemoveAll(dir) + return pluginRecord{}, errors.New("plugin is already installed; use upgrade") + } + for i := range old.Revisions { + if old.Revisions[i].Manifest.PluginID == "" { + old.Revisions[i].Manifest = old.Manifest + } + } + p.Revisions = append(old.Revisions, p.Revisions...) + if preserveActive { + p.Manifest = old.Manifest + p.ActiveRevision = old.ActiveRevision + p.PendingRevision = revisionID + p.PreviousState = old.State + p.State = "upgrading" + } else { + p.ActiveRevision = revisionID + } + // An endpoint belongs to a service mode. A command revision gets a new + // managed port; an external revision must be configured explicitly when + // switching from a command revision. + if old.Manifest.Backend.Command == "" && m.Backend.Command == "" { + p.Endpoint = old.Endpoint + } + p.ConfigCipher = old.ConfigCipher + if preserveActive { + p.LastError = "" + } + } + a.registry.data.Plugins[m.PluginID] = p + err := a.registry.saveLocked() + if err != nil { + if existed { + a.registry.data.Plugins[m.PluginID] = old + } else { + delete(a.registry.data.Plugins, m.PluginID) + } + if restoreErr := a.registry.saveLocked(); restoreErr != nil { + err = fmt.Errorf("registry save failed: %v; restore failed: %w", err, restoreErr) + } + } + a.registry.mu.Unlock() + if err != nil { + _ = os.RemoveAll(dir) + return pluginRecord{}, err + } + return p, nil +} + +func readUpload(r *http.Request) ([]byte, error) { + if strings.HasPrefix(strings.ToLower(r.Header.Get("Content-Type")), "multipart/form-data") { + if err := r.ParseMultipartForm(maxPackageBytes); err != nil { + return nil, err + } + file, _, err := r.FormFile("package") + if err != nil { + file, _, err = r.FormFile("artifact") + } + if err != nil { + return nil, errors.New("multipart field package is required") + } + defer file.Close() + return io.ReadAll(io.LimitReader(file, maxPackageBytes+1)) + } + return io.ReadAll(io.LimitReader(r.Body, maxPackageBytes+1)) +} + +func (a *app) install(w http.ResponseWriter, r *http.Request, upgrade bool, routeID string) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + expectedID := strings.TrimSpace(routeID) + operationKind := map[bool]string{false: "install", true: "upgrade"}[upgrade] + raw, err := readUpload(r) + if err == nil && len(raw) > maxPackageBytes { + err = errors.New("package exceeds size limit") + } + op, s, ok := a.mutationAuthWithHash(w, r, operationKind, expectedID, operationHashWithBody(r, raw)) + if !ok { + return + } + var unlock func() + if upgrade && expectedID != "" { + unlock = a.lockPlugin(expectedID) + defer unlock() + } + var p pluginRecord + previousState := "disabled" + var oldRecord pluginRecord + var oldExists bool + if upgrade && expectedID != "" { + a.registry.mu.Lock() + if old, exists := a.registry.data.Plugins[expectedID]; exists && old.State != "" { + previousState = old.State + oldRecord = clonePluginRecord(old) + oldExists = true + } else { + err = errors.New("plugin to upgrade was not found") + } + a.registry.mu.Unlock() + } + if err == nil { + info, inspectErr := a.inspectPackage(raw) + err = inspectErr + if err == nil && expectedID != "" && info.Manifest.PluginID != expectedID { + err = errors.New("upgrade plugin_id does not match route") + } + if err == nil && expectedID == "" { + if setErr := a.registry.setOperationPlugin(op.ID, info.Manifest.PluginID); setErr != nil { + err = setErr + } + } + if err == nil { + if upgrade { + p, err = a.installPackageMode(info, true) + if err == nil { + candidate := p + candidate.ActiveRevision = p.PendingRevision + candidateRevision := revisionByID(&p, p.PendingRevision) + candidate.Manifest = candidateRevision.Manifest + candidateKey := p.Manifest.PluginID + "#" + p.PendingRevision + candidateEndpoint, probeErr := a.startRevision(&candidate, p.PendingRevision, candidateKey) + err = probeErr + if err == nil { + if previousState != "healthy" { + a.stopProcess(candidateKey) + } + if previousState == "healthy" { + p.Endpoint = candidateEndpoint + } + p.Manifest = candidateRevision.Manifest + p.ActiveRevision = p.PendingRevision + p.PendingRevision = "" + p.PreviousState = "" + p.State = previousState + if p.State == "upgrading" || p.State == "starting" || p.State == "draining" { + p.State = "disabled" + } + p.LastError = "" + a.registry.mu.Lock() + p.UpdatedAt = time.Now().UTC() + a.registry.data.Plugins[p.Manifest.PluginID] = p + saveErr := a.registry.saveLocked() + if saveErr != nil { + if oldExists { + a.registry.data.Plugins[p.Manifest.PluginID] = oldRecord + } else { + delete(a.registry.data.Plugins, p.Manifest.PluginID) + } + if restoreErr := a.registry.saveLocked(); restoreErr != nil { + saveErr = fmt.Errorf("registry save failed: %v; restore failed: %w", saveErr, restoreErr) + } + } + a.registry.mu.Unlock() + err = saveErr + if err != nil { + a.stopProcess(candidateKey) + } else if previousState == "healthy" { + // Keep the old process alive until the registry commit has + // succeeded, then atomically switch the process handle. + a.stopPlugin(p.Manifest.PluginID) + a.promoteProcess(candidateKey, p.Manifest.PluginID) + } + } else { + p.State = "rollback_pending" + p.LastError = sanitizeError(err) + a.registry.mu.Lock() + a.registry.data.Plugins[p.Manifest.PluginID] = p + saveErr := a.registry.saveLocked() + if saveErr != nil { + if oldExists { + a.registry.data.Plugins[p.Manifest.PluginID] = oldRecord + } else { + delete(a.registry.data.Plugins, p.Manifest.PluginID) + } + restoreErr := a.registry.saveLocked() + err = fmt.Errorf("candidate probe failed: %v; registry save failed: %v", err, saveErr) + if restoreErr != nil { + err = fmt.Errorf("%v; restore failed: %w", err, restoreErr) + } + } + a.registry.mu.Unlock() + } + } + } else { + if unlock == nil { + unlock = a.lockPlugin(info.Manifest.PluginID) + defer unlock() + } + p, err = a.installPackageMode(info, false) + } + } + } + if err == nil { + op.Revision = p.ActiveRevision + } + pluginAuditID := p.Manifest.PluginID + if pluginAuditID == "" { + pluginAuditID = expectedID + } + op, persistErr := a.finalizeOperation(op, err, auditEvent{Time: time.Now().UTC(), Action: op.Kind, PluginID: pluginAuditID, ActorID: s.User["id"], RequestID: requestID(r)}) + if persistErr != nil { + writeOperationPersistenceError(w, op) + return + } + a.operationResponse(w, op) +} + +func (a *app) removeOwnMenu(ctx context.Context, access, id string, rid string) error { + if a.core == nil { + return errors.New("core unavailable") + } + settings, err := a.core.settings(ctx, access, rid) + if err != nil { + return err + } + data := envelopeData(settings) + raw := menuItemsFromSettings(data) + next := make([]any, 0, len(raw)) + for _, item := range raw { + object, ok := item.(map[string]any) + if ok && object["id"] == id { + continue + } + next = append(next, item) + } + if len(next) == len(raw) { + return nil + } + _, err = a.core.updateMenu(ctx, access, rid, next) + return err +} + +func (a *app) restoreOwnMenu(ctx context.Context, access string, p pluginRecord, rid string) error { + if a.core == nil { + return errors.New("core unavailable") + } + itemURL := p.Manifest.UI.Menu.URL + if itemURL == "" { + cfg, err := a.decryptConfig(p.ConfigCipher) + if err != nil { + return err + } + itemURL, _ = cfg["public_url"].(string) + } + if itemURL == "" || validateMenuURL(itemURL) != nil { + return errors.New("menu URL is not configured") + } + settings, err := a.core.settings(ctx, access, rid) + if err != nil { + return err + } + current := menuItemsFromSettings(envelopeData(settings)) + candidate := map[string]any{"id": p.Manifest.UI.Menu.ID, "label": p.Manifest.UI.Menu.Label, "url": itemURL, "visibility": "admin", "sort_order": p.Manifest.UI.Menu.SortOrder} + replaced := false + for i, raw := range current { + if object, ok := raw.(map[string]any); ok && object["id"] == p.Manifest.UI.Menu.ID { + current[i] = candidate + replaced = true + } + } + if !replaced { + current = append(current, candidate) + } + _, err = a.core.updateMenu(ctx, access, rid, current) + return err +} + +func (a *app) withPlugin(w http.ResponseWriter, r *http.Request, kind string, fn func(*pluginRecord, session) error) { + id, _, ok := a.pluginIDFromPath(r) + if !ok { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid plugin id"}) + return + } + op, s, ok := a.mutationAuth(w, r, kind, id) + if !ok { + return + } + unlock := a.lockPlugin(id) + defer unlock() + a.registry.mu.Lock() + p, found := a.registry.data.Plugins[id] + a.registry.mu.Unlock() + if found { + p = clonePluginRecord(p) + } + old := clonePluginRecord(p) + var err error + if !found { + err = errors.New("plugin not found") + } else { + err = fn(&p, s) + } + if err == nil && kind != "uninstall" { + a.registry.mu.Lock() + p.UpdatedAt = time.Now().UTC() + a.registry.data.Plugins[id] = p + saveErr := a.registry.saveLocked() + if saveErr != nil { + a.registry.data.Plugins[id] = old + if restoreErr := a.registry.saveLocked(); restoreErr != nil { + saveErr = fmt.Errorf("registry save failed: %v; restore failed: %w", saveErr, restoreErr) + } + } + a.registry.mu.Unlock() + err = saveErr + if err != nil { + a.restorePluginRuntime(old, p) + if kind == "disable" { + _ = a.restoreOwnMenu(r.Context(), s.AccessToken, old, requestID(r)) + } + } + } else if err == nil && kind == "uninstall" { + // Move revision directories to private tombstones before changing the + // registry. This keeps both the registry and files recoverable if either + // the rename or registry commit fails. + moves, stageErr := a.stageRevisionPaths(old.Revisions) + err = stageErr + if err == nil { + a.registry.mu.Lock() + delete(a.registry.data.Plugins, id) + saveErr := a.registry.saveLocked() + if saveErr != nil { + a.registry.data.Plugins[id] = old + if restoreErr := a.registry.saveLocked(); restoreErr != nil { + saveErr = fmt.Errorf("registry save failed: %v; restore failed: %w", saveErr, restoreErr) + } + } + a.registry.mu.Unlock() + err = saveErr + if err != nil { + if restoreErr := restoreRevisionPaths(moves); restoreErr != nil { + err = fmt.Errorf("%v; revision restore failed: %w", err, restoreErr) + } + _ = a.restoreOwnMenu(r.Context(), s.AccessToken, old, requestID(r)) + } else if cleanupErr := removeStagedRevisionPaths(moves); cleanupErr != nil { + // The registry commit is already complete; keep the failed cleanup + // visible without restoring a record that may point at partially + // removed files. The private tombstones are safe to clean manually. + err = fmt.Errorf("plugin uninstalled but resource cleanup failed: %w", cleanupErr) + } + } else { + _ = a.restoreOwnMenu(r.Context(), s.AccessToken, old, requestID(r)) + } + } + op, persistErr := a.finalizeOperation(op, err, auditEvent{Time: time.Now().UTC(), Action: kind, PluginID: id, ActorID: s.User["id"], RequestID: requestID(r)}) + if persistErr != nil { + writeOperationPersistenceError(w, op) + return + } + a.operationResponse(w, op) +} + +func (a *app) restorePluginRuntime(old, current pluginRecord) { + if current.State == "healthy" && old.State != "healthy" { + a.stopPlugin(current.Manifest.PluginID) + return + } + if current.State != "healthy" && old.State == "healthy" { + a.stopPlugin(current.Manifest.PluginID) + candidate := old + if err := a.startPlugin(&candidate); err == nil { + return + } + } + if current.State == "healthy" && old.State == "healthy" && current.ActiveRevision != old.ActiveRevision { + a.stopPlugin(current.Manifest.PluginID) + candidate := old + _ = a.startPlugin(&candidate) + } +} + +func (a *app) enable(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + a.withPlugin(w, r, "enable", func(p *pluginRecord, _ session) error { + compat := p.Manifest.EvaluateCompatibility(a.currentCoreVersion(r.Context())) + if !compat.Compatible { + p.State = "incompatible" + return errors.New("plugin is incompatible with current Core") + } + if compat.Status == "untested" && r.URL.Query().Get("accept_untested") != "true" { + return errors.New("untested Core version requires explicit acceptance") + } + p.State = "starting" + if err := a.startPlugin(p); err != nil { + a.stopPlugin(p.Manifest.PluginID) + p.State = "error" + p.LastError = sanitizeError(err) + return err + } + p.State = "healthy" + p.LastError = "" + for i := range p.Revisions { + if p.Revisions[i].ID == p.ActiveRevision { + p.Revisions[i].HealthyAt = time.Now().UTC() + } + } + return nil + }) +} + +func (a *app) disable(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + a.withPlugin(w, r, "disable", func(p *pluginRecord, s session) error { + if err := a.removeOwnMenu(r.Context(), s.AccessToken, p.Manifest.PluginID, requestID(r)); err != nil { + return err + } + p.State = "draining" + a.stopPlugin(p.Manifest.PluginID) + p.State = "disabled" + return nil + }) +} + +func (a *app) rollback(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + a.withPlugin(w, r, "rollback", func(p *pluginRecord, _ session) error { + targetState := p.State + if p.PreviousState != "" { + targetState = p.PreviousState + } + // A failed upgrade leaves the previous active revision untouched and + // stores the new revision as pending. Rolling back in that state means + // discarding the failed candidate, not attempting to boot it again. + if p.PendingRevision != "" && p.ActiveRevision != "" { + pendingID := p.PendingRevision + if targetState == "healthy" { + if err := a.checkPluginHealth(p); err != nil { + p.LastError = sanitizeError(err) + return err + } + } + kept := p.Revisions[:0] + for i := range p.Revisions { + if p.Revisions[i].ID == pendingID { + // Keep the candidate until a maintenance pass can remove its + // files after this registry update has committed. + p.Revisions[i].Retired = true + } + kept = append(kept, p.Revisions[i]) + } + p.Revisions = kept + p.PendingRevision = "" + p.PreviousState = "" + p.State = targetState + if p.State == "starting" || p.State == "draining" || p.State == "upgrading" || p.State == "rollback_pending" { + p.State = "disabled" + } + p.LastError = "" + return nil + } + if len(p.Revisions) < 2 { + p.State = "disabled" + return errors.New("no rollback revision retained") + } + current := p.ActiveRevision + var target revision + found := false + for i := len(p.Revisions) - 1; i >= 0; i-- { + if p.Revisions[i].Retired || p.Revisions[i].ID == current || (p.PendingRevision != "" && p.Revisions[i].ID == p.PendingRevision) { + continue + } + target = p.Revisions[i] + if target.Manifest.PluginID == "" { + target.Manifest = p.Manifest + } + found = true + break + } + if !found { + return errors.New("rollback revision not found") + } + candidate := *p + candidate.Manifest = target.Manifest + candidate.ActiveRevision = target.ID + candidateKey := p.Manifest.PluginID + "#rollback" + endpoint, err := a.startRevision(&candidate, target.ID, candidateKey) + if err != nil { + p.State = "rollback_pending" + p.LastError = sanitizeError(err) + return err + } + if targetState == "healthy" { + a.stopPlugin(p.Manifest.PluginID) + a.promoteProcess(candidateKey, p.Manifest.PluginID) + p.Endpoint = endpoint + } else { + a.stopProcess(candidateKey) + } + p.ActiveRevision = target.ID + p.Manifest = target.Manifest + p.PendingRevision = "" + p.PreviousState = "" + p.State = targetState + if p.State == "starting" || p.State == "draining" || p.State == "upgrading" || p.State == "rollback_pending" { + p.State = "disabled" + } + p.LastError = "" + return nil + }) +} + +func (a *app) uninstall(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + a.withPlugin(w, r, "uninstall", func(p *pluginRecord, s session) error { + if p.State == "healthy" || p.State == "starting" || p.State == "draining" { + return errors.New("disable plugin before uninstall") + } + if err := a.removeOwnMenu(r.Context(), s.AccessToken, p.Manifest.PluginID, requestID(r)); err != nil { + return err + } + a.stopPlugin(p.Manifest.PluginID) + return nil + }) +} + +func (a *app) config(w http.ResponseWriter, r *http.Request) { + id, action, ok := a.pluginIDFromPath(r) + if !ok || action != "config" { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) + return + } + if r.Method == http.MethodGet { + if _, _, ok := a.authenticate(w, r); !ok { + return + } + a.registry.mu.Lock() + p, found := a.registry.data.Plugins[id] + a.registry.mu.Unlock() + if !found { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "plugin not found"}) + return + } + cfg, cfgErr := a.decryptConfig(p.ConfigCipher) + if cfgErr != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "plugin configuration is unavailable"}) + return + } + public := map[string]any{} + for key, value := range cfg { + if isSecretKey(key) { + public[key] = map[string]any{"configured": strings.TrimSpace(fmt.Sprint(value)) != ""} + continue + } + public[key] = value + } + writeJSON(w, http.StatusOK, map[string]any{"config": public, "endpoint": p.Endpoint}) + return + } + if r.Method != http.MethodPut { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + a.withPlugin(w, r, "config", func(p *pluginRecord, _ session) error { + var cfg map[string]any + if err := decodeJSON(r, &cfg, maxJSONBytes); err != nil || cfg == nil { + return errors.New("config must be a JSON object") + } + for key := range cfg { + normalized := strings.ReplaceAll(strings.ReplaceAll(strings.ToLower(strings.TrimSpace(key)), "-", "_"), " ", "_") + if strings.Contains(normalized, "core_token") || strings.Contains(normalized, "core_access_token") || strings.Contains(normalized, "core_refresh_token") || strings.Contains(normalized, "admin_key") || strings.Contains(normalized, "admin_api_key") { + return errors.New("credential field is not accepted") + } + } + cipherText, err := a.encryptConfig(cfg) + if err != nil { + return err + } + p.ConfigCipher = cipherText + if p.Manifest.Backend.Command == "" { + endpoint, ok := cfg["service_url"].(string) + if !ok || strings.TrimSpace(endpoint) == "" { + return errors.New("service_url is required for external plugins") + } + if err := validateServiceURL(endpoint); err != nil { + return err + } + endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/") + if p.State == "healthy" && endpoint != p.Endpoint { + return errors.New("disable plugin before changing service_url") + } + p.Endpoint = endpoint + } else { + // Managed command plugins receive their endpoint from the supervisor. + p.Endpoint = "" + } + return nil + }) +} + +func isSecretKey(key string) bool { + lower := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(key), "-", "_"), " ", "_")) + compact := strings.ReplaceAll(lower, "_", "") + for _, marker := range []string{"secret", "password", "token", "apikey", "privatekey", "credential", "authorization", "cookie", "session", "key"} { + if strings.Contains(compact, marker) { + return true + } + } + return false +} + +func (a *app) cipherKey() []byte { + if len(a.configKey) == 32 { + return a.configKey + } + return nil +} + +func (a *app) encryptConfig(value map[string]any) (string, error) { + if len(a.cipherKey()) != 32 { + return "", errors.New("PLUGIN_CONFIG_KEY is required") + } + plain, err := json.Marshal(value) + if err != nil { + return "", err + } + block, err := aes.NewCipher(a.cipherKey()) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", err + } + return base64.RawStdEncoding.EncodeToString(gcm.Seal(nonce, nonce, plain, nil)), nil +} + +func (a *app) decryptConfig(encoded string) (map[string]any, error) { + if encoded == "" { + return map[string]any{}, nil + } + raw, err := base64.RawStdEncoding.DecodeString(encoded) + if err != nil { + return nil, err + } + if len(a.cipherKey()) != 32 { + return nil, errors.New("PLUGIN_CONFIG_KEY is required") + } + block, err := aes.NewCipher(a.cipherKey()) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + if len(raw) < gcm.NonceSize() { + return nil, errors.New("invalid encrypted config") + } + plain, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil) + if err != nil { + return nil, err + } + var out map[string]any + if err := json.Unmarshal(plain, &out); err != nil { + return nil, err + } + return out, nil +} + +func validateServiceURL(raw string) error { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return errors.New("service_url must be an absolute http(s) origin without credentials or query") + } + if !isLoopbackHost(u.Hostname()) { + return errors.New("service_url must point to a loopback plugin service") + } + return nil +} + +func validateMenuURL(raw string) error { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return errors.New("menu URL must be an absolute http(s) origin") + } + if u.Scheme == "http" && !isLoopbackHost(u.Hostname()) { + return errors.New("menu URL must use HTTPS unless loopback") + } + return nil +} + +func (a *app) startPlugin(p *pluginRecord) error { + endpoint, err := a.startRevision(p, p.ActiveRevision, p.Manifest.PluginID) + if err != nil { + return err + } + p.Endpoint = endpoint + return nil +} + +func (a *app) startRevision(p *pluginRecord, revisionID, processKey string) (string, error) { + if revisionID == "" { + return "", errors.New("revision is required") + } + rev := revisionByID(p, revisionID) + pluginManifest := p.Manifest + if rev.Manifest.PluginID != "" { + pluginManifest = rev.Manifest + } + endpoint := "" + if pluginManifest.Backend.Command != "" { + commandPath := filepath.Join(a.root, "installed", pluginManifest.PluginID, revisionID, filepath.FromSlash(pluginManifest.Backend.Command)) + if rev.Path != "" { + commandPath = filepath.Join(rev.Path, filepath.FromSlash(pluginManifest.Backend.Command)) + } + if _, err := os.Stat(commandPath); err != nil { + return "", err + } + port, err := freeLoopbackPort() + if err != nil { + return "", err + } + cmd := exec.Command(commandPath) + cmd.Dir = filepath.Dir(commandPath) + cmd.Env = []string{"PATH=/usr/bin:/bin", "HOME=" + filepath.Dir(commandPath), "LANG=C", "PLUGIN_ID=" + pluginManifest.PluginID, pluginManifest.Backend.ListenEnv + "=" + port} + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := cmd.Start(); err != nil { + return "", err + } + endpoint = "http://127.0.0.1:" + port + a.mu.Lock() + a.processes[processKey] = cmd + a.mu.Unlock() + } else { + // External services are owned by their deployer. Never inherit a + // command-process endpoint or a stale endpoint across a mode switch. + endpoint = p.Endpoint + } + probe := *p + probe.Manifest = pluginManifest + probe.ActiveRevision = revisionID + probe.Endpoint = endpoint + var probeErr error + attempts := 1 + if pluginManifest.Backend.Command != "" { + // A freshly spawned process can need a short interval before binding its + // port. Retry the probe within the bounded startup window. + attempts = 20 + } + for attempt := 0; attempt < attempts; attempt++ { + probeErr = a.checkPluginHealth(&probe) + if probeErr == nil { + break + } + if attempt+1 < attempts { + time.Sleep(50 * time.Millisecond) + } + } + if probeErr != nil { + a.stopProcess(processKey) + return "", probeErr + } + return endpoint, nil +} + +func revisionByID(p *pluginRecord, id string) revision { + for _, rev := range p.Revisions { + if rev.ID == id { + if rev.Manifest.PluginID == "" { + rev.Manifest = p.Manifest + } + return rev + } + } + return revision{ID: id, Manifest: p.Manifest} +} + +func freeLoopbackPort() (string, error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return "", err + } + port := strconv.Itoa(listener.Addr().(*net.TCPAddr).Port) + if err := listener.Close(); err != nil { + return "", err + } + return port, nil +} + +func (a *app) checkPluginHealth(p *pluginRecord) error { + if p.Endpoint == "" { + return errors.New("service_url is required before enabling") + } + u, err := url.Parse(p.Endpoint) + if err != nil { + return err + } + if err := validateServiceURL(p.Endpoint); err != nil { + return err + } + health := strings.TrimRight(p.Endpoint, "/") + "/" + strings.Trim(strings.TrimSpace(p.Manifest.Backend.HealthPath), "/") + req, err := http.NewRequest(http.MethodGet, health, nil) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + req = req.WithContext(ctx) + client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{Proxy: nil}, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }} + res, err := client.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + body, err := io.ReadAll(io.LimitReader(res.Body, 64<<10)) + if err != nil { + return err + } + _ = u + if res.StatusCode < 200 || res.StatusCode >= 300 { + return fmt.Errorf("plugin health check returned %d", res.StatusCode) + } + if version := responseVersion(body); version != "" && strings.TrimPrefix(version, "v") != strings.TrimPrefix(p.Manifest.Version, "v") { + return fmt.Errorf("plugin version mismatch: got %s", version) + } + ready := strings.TrimRight(p.Endpoint, "/") + "/" + strings.Trim(strings.TrimSpace(p.Manifest.Backend.ReadinessPath), "/") + readyReq, err := http.NewRequestWithContext(ctx, http.MethodGet, ready, nil) + if err != nil { + return err + } + readyRes, err := client.Do(readyReq) + if err != nil { + return err + } + defer readyRes.Body.Close() + if readyRes.StatusCode < 200 || readyRes.StatusCode >= 300 { + return fmt.Errorf("plugin readiness check returned %d", readyRes.StatusCode) + } + return nil +} + +func responseVersion(body []byte) string { + var value map[string]any + if json.Unmarshal(body, &value) != nil { + return "" + } + version, _ := value["version"].(string) + return strings.TrimSpace(version) +} + +func (a *app) stopPlugin(id string) { + a.stopProcess(id) +} + +func (a *app) stopProcess(id string) { + a.mu.Lock() + cmd := a.processes[id] + delete(a.processes, id) + a.mu.Unlock() + if cmd != nil && cmd.Process != nil { + // Kill the process group so a plugin cannot leave a child server behind + // after disable, upgrade, rollback or control-plane shutdown. + if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM); err != nil { + _ = cmd.Process.Signal(syscall.SIGTERM) + } + finished := make(chan struct{}) + go func() { _, _ = cmd.Process.Wait(); close(finished) }() + select { + case <-finished: + case <-time.After(10 * time.Second): + _ = cmd.Process.Kill() + <-finished + } + } +} + +func (a *app) shutdown() { + a.mu.Lock() + ids := make([]string, 0, len(a.processes)) + for id := range a.processes { + ids = append(ids, id) + } + a.mu.Unlock() + for _, id := range ids { + a.stopProcess(id) + } +} + +func (a *app) promoteProcess(from, to string) { + a.mu.Lock() + if cmd, ok := a.processes[from]; ok { + a.processes[to] = cmd + delete(a.processes, from) + } + a.mu.Unlock() +} + +// recoverPlugins reconciles persisted lifecycle state with the processes and +// external endpoints that exist after a control-plane restart. A healthy +// command plugin is started again; an external plugin is only probed. Any +// interrupted transition is failed closed instead of being advertised as +// healthy with no corresponding runtime. +func (a *app) recoverPlugins() error { + a.registry.mu.Lock() + ids := make([]string, 0, len(a.registry.data.Plugins)) + for id := range a.registry.data.Plugins { + ids = append(ids, id) + } + a.registry.mu.Unlock() + sort.Strings(ids) + for _, id := range ids { + unlock := a.lockPlugin(id) + a.registry.mu.Lock() + p, ok := a.registry.data.Plugins[id] + a.registry.mu.Unlock() + if !ok { + unlock() + continue + } + old := clonePluginRecord(p) + var err error + switch p.State { + case "healthy", "enabled": + compat := p.Manifest.EvaluateCompatibility(a.currentCoreVersion(context.Background())) + if !compat.Compatible { + err = errors.New("plugin is incompatible with current Core") + } else if p.ActiveRevision == "" { + err = errors.New("active revision is missing") + } else { + candidate := p + endpoint, probeErr := a.startRevision(&candidate, p.ActiveRevision, p.Manifest.PluginID) + err = probeErr + if err == nil { + p.Endpoint = endpoint + p.State = "healthy" + p.LastError = "" + for i := range p.Revisions { + if p.Revisions[i].ID == p.ActiveRevision { + p.Revisions[i].HealthyAt = time.Now().UTC() + } + } + } + } + if err != nil { + p.State = "error" + p.LastError = sanitizeError(err) + p.Endpoint = "" + } + case "starting", "draining", "upgrading", "rollback_pending": + a.stopPlugin(p.Manifest.PluginID) + p.State = "error" + p.LastError = "control-plane restart interrupted plugin operation" + default: + unlock() + continue + } + p.UpdatedAt = time.Now().UTC() + a.registry.mu.Lock() + a.registry.data.Plugins[id] = p + saveErr := a.registry.saveLocked() + if saveErr != nil { + a.registry.data.Plugins[id] = old + if restoreErr := a.registry.saveLocked(); restoreErr != nil { + saveErr = fmt.Errorf("registry save failed: %v; restore failed: %w", saveErr, restoreErr) + } + } + a.registry.mu.Unlock() + unlock() + if saveErr != nil { + if p.Manifest.Backend.Command != "" { + a.stopPlugin(p.Manifest.PluginID) + } + return saveErr + } + } + return nil +} + +func (a *app) audit(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + if _, _, ok := a.authenticate(w, r); !ok { + return + } + a.registry.mu.Lock() + items := append([]auditEvent(nil), a.registry.data.Audit...) + a.registry.mu.Unlock() + writeJSON(w, http.StatusOK, map[string]any{"items": items}) +} + +func (a *app) menu(w http.ResponseWriter, r *http.Request, apply bool) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + id, _, ok := a.pluginIDFromPath(r) + if !ok { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid plugin id"}) + return + } + kind := "menu_preview" + if apply { + kind = "menu_apply" + } + op, s, ok := a.mutationAuth(w, r, kind, id) + if !ok { + return + } + unlock := a.lockPlugin(id) + defer unlock() + a.registry.mu.Lock() + p, found := a.registry.data.Plugins[id] + a.registry.mu.Unlock() + var err error + var current []any + if !found { + err = errors.New("plugin not found") + } + if err == nil && apply && (p.State != "healthy" && p.State != "enabled") { + err = errors.New("plugin must be healthy before menu injection") + } + if err == nil { + settings, callErr := a.core.settings(r.Context(), s.AccessToken, requestID(r)) + if callErr != nil { + err = callErr + } else { + data := envelopeData(settings) + current = menuItemsFromSettings(data) + } + } + itemURL := p.Manifest.UI.Menu.URL + if itemURL == "" { + cfg, cfgErr := a.decryptConfig(p.ConfigCipher) + if cfgErr != nil { + err = errors.New("plugin configuration is unavailable") + } else if configured, ok := cfg["public_url"].(string); ok { + itemURL = configured + } + } + if itemURL == "" { + err = errors.New("menu URL is not configured") + } else if validateMenuURL(itemURL) != nil { + err = errors.New("menu URL must be an absolute http(s) URL") + } + next := append([]any(nil), current...) + if err == nil { + candidate := map[string]any{"id": p.Manifest.UI.Menu.ID, "label": p.Manifest.UI.Menu.Label, "url": itemURL, "visibility": "admin", "sort_order": p.Manifest.UI.Menu.SortOrder} + replaced := false + for i, raw := range next { + if object, ok := raw.(map[string]any); ok && object["id"] == p.Manifest.UI.Menu.ID { + next[i] = candidate + replaced = true + } + } + if !replaced { + next = append(next, candidate) + } + } + if apply && err == nil { + _, err = a.core.updateMenu(r.Context(), s.AccessToken, requestID(r), next) + } + op, persistErr := a.finalizeOperation(op, err, auditEvent{Time: time.Now().UTC(), Action: kind, PluginID: id, ActorID: s.User["id"], RequestID: requestID(r)}) + if persistErr != nil { + writeOperationPersistenceError(w, op) + return + } + if apply { + a.operationResponse(w, op) + return + } + writeJSON(w, http.StatusOK, map[string]any{"operation_id": op.ID, "current": current, "next": next, "state": op.State, "error": op.Error}) +} + +func (a *app) menuGlobal(w http.ResponseWriter, r *http.Request, apply bool) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + var input struct { + PluginID string `json:"plugin_id"` + } + raw, bodyErr := captureRequestBody(r, 32<<10) + if bodyErr != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + var trailing any + if err := dec.Decode(&input); err != nil || dec.Decode(&trailing) != io.EOF || strings.TrimSpace(input.PluginID) == "" || strings.ContainsAny(input.PluginID, "/\\") { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "plugin_id is required"}) + return + } + // a.menu performs its own authenticated idempotency check and needs the + // original body available for its server-side request fingerprint. + r.Body = io.NopCloser(bytes.NewReader(raw)) + pathAction := "menu-preview" + if apply { + pathAction = "menu-apply" + } + r.URL.Path = "/api/plugins/" + input.PluginID + "/" + pathAction + a.menu(w, r, apply) +} + +func (a *app) coreError(w http.ResponseWriter, err error, fallback string) { + status := http.StatusBadGateway + if ce, ok := err.(*coreError); ok && ce.status >= 400 && ce.status < 600 { + status = ce.status + } + writeJSON(w, status, map[string]string{"error": fallback}) +} + +func (a *app) static(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/admin" { + http.Redirect(w, r, a.publicBasePath+"/admin/", http.StatusPermanentRedirect) + return + } + if r.URL.Path == "/" || r.URL.Path == "/admin/" { + data, err := uiFS.ReadFile("ui/index.html") + if err != nil { + http.Error(w, "ui unavailable", 500) + return + } + baseJSON, _ := json.Marshal(a.publicBasePath) + data = []byte(strings.ReplaceAll(string(data), "__PLUGIN_BASE_PATH_JSON__", string(baseJSON))) + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(data) + return + } + for _, name := range []string{"app.js", "styles.css"} { + if r.URL.Path == "/"+name { + data, err := uiFS.ReadFile("ui/" + name) + if err != nil { + http.NotFound(w, r) + return + } + if strings.HasSuffix(name, ".js") { + w.Header().Set("Content-Type", "text/javascript; charset=utf-8") + } else { + w.Header().Set("Content-Type", "text/css; charset=utf-8") + } + _, _ = w.Write(data) + return + } + } + http.NotFound(w, r) +} + +func (a *app) routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/healthz", a.health) + mux.HandleFunc("/readyz", a.ready) + mux.HandleFunc("/login", a.login) + mux.HandleFunc("/login/2fa", a.login2FA) + mux.HandleFunc("/logout", a.logout) + mux.HandleFunc("/api/me", a.me) + mux.HandleFunc("/api/audit", a.audit) + mux.HandleFunc("/api/menu-items/preview", func(w http.ResponseWriter, r *http.Request) { a.menuGlobal(w, r, false) }) + mux.HandleFunc("/api/menu-items/apply", func(w http.ResponseWriter, r *http.Request) { a.menuGlobal(w, r, true) }) + mux.HandleFunc("/api/operations/", a.operationByID) + mux.HandleFunc("/api/plugins", a.apiPlugins) + mux.HandleFunc("/api/plugins/install", func(w http.ResponseWriter, r *http.Request) { a.install(w, r, false, "") }) + mux.HandleFunc("/api/plugins/", func(w http.ResponseWriter, r *http.Request) { + id, action, ok := a.pluginIDFromPath(r) + if !ok { + http.NotFound(w, r) + return + } + if action == "" { + a.getPlugin(w, r) + return + } + switch action { + case "install": + a.install(w, r, false, id) + case "upgrade": + a.install(w, r, true, id) + case "enable": + a.enable(w, r) + case "disable": + a.disable(w, r) + case "rollback": + a.rollback(w, r) + case "uninstall": + a.uninstall(w, r) + case "config": + a.config(w, r) + case "menu-preview": + a.menu(w, r, false) + case "menu-apply": + a.menu(w, r, true) + default: + http.NotFound(w, r) + } + }) + mux.HandleFunc("/", a.static) + return a.securityHeaders(requestIDMiddleware(mux)) +} + +func requestIDMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rid := requestID(r) + w.Header().Set("X-Request-Id", rid) + next.ServeHTTP(w, r) + }) +} + +func (a *app) securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; frame-ancestors "+strings.Join(a.frameAncestors, " ")+"; base-uri 'self'; form-action 'self'") + next.ServeHTTP(w, r) + }) +} + +func parseSameSite(value string) (http.SameSite, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "lax": + return http.SameSiteLaxMode, nil + case "strict": + return http.SameSiteStrictMode, nil + case "none": + return http.SameSiteNoneMode, nil + default: + return 0, errors.New("PLUGIN_COOKIE_SAMESITE must be lax, strict or none") + } +} + +func loadTrustedPublishers(raw string) map[string][]byte { + out := map[string][]byte{} + var values map[string]string + if json.Unmarshal([]byte(raw), &values) != nil { + return out + } + for key, encoded := range values { + if data, err := base64.StdEncoding.DecodeString(encoded); err == nil && len(data) == 32 { + out[key] = data + } + } + return out +} + +func parseFrameAncestors(raw string) []string { + values := strings.Fields(raw) + if len(values) == 0 { + return []string{"'self'"} + } + allowed := make([]string, 0, len(values)) + for _, value := range values { + if value == "'self'" || value == "'none'" { + allowed = append(allowed, value) + continue + } + u, err := url.Parse(value) + if err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" && u.User == nil && u.Path == "" && u.RawQuery == "" && u.Fragment == "" { + allowed = append(allowed, value) + } + } + if len(allowed) == 0 { + return []string{"'self'"} + } + return allowed +} + +func deriveConfigKey(raw string) ([]byte, error) { + raw = strings.TrimSpace(raw) + if len(raw) < 32 { + return nil, errors.New("PLUGIN_CONFIG_KEY must contain at least 32 characters") + } + hash := sha256.Sum256([]byte(raw)) + return hash[:], nil +} + +func main() { + host := os.Getenv("PLUGIN_HOST") + if host == "" { + host = "127.0.0.1" + } + port := os.Getenv("PLUGIN_PORT") + if port == "" { + port = "8090" + } + coreURL := os.Getenv("CORE_BASE_URL") + if coreURL == "" { + coreURL = "http://127.0.0.1:8080" + } + core, err := newCoreClient(coreURL) + if err != nil { + slog.Error("invalid Core URL", "error", err) + os.Exit(2) + } + registryDir := os.Getenv("PLUGIN_REGISTRY_DIR") + if registryDir == "" { + registryDir = "./data" + } + reg, err := openRegistry(registryDir) + if err != nil { + slog.Error("open registry", "error", err) + os.Exit(2) + } + a := newApp(core, reg, registryDir) + environment := strings.ToLower(strings.TrimSpace(os.Getenv("PLUGIN_ENV"))) + if environment == "" { + environment = "production" + } + a.publicBasePath = strings.TrimRight(strings.TrimSpace(os.Getenv("PLUGIN_PUBLIC_BASE_PATH")), "/") + a.cookiePath = os.Getenv("PLUGIN_COOKIE_PATH") + if a.cookiePath == "" { + a.cookiePath = "/" + } + a.cookieSecure = strings.EqualFold(os.Getenv("PLUGIN_COOKIE_SECURE"), "true") + if !isLoopbackHost(host) && !a.cookieSecure { + slog.Error("PLUGIN_COOKIE_SECURE must be true for non-loopback listeners") + os.Exit(2) + } + if sameSite, parseErr := parseSameSite(os.Getenv("PLUGIN_COOKIE_SAMESITE")); parseErr == nil { + a.cookieSameSite = sameSite + } else { + slog.Error("invalid cookie same site", "error", parseErr) + os.Exit(2) + } + if a.cookieSameSite == http.SameSiteNoneMode && !a.cookieSecure { + slog.Error("SameSite=None requires secure cookie") + os.Exit(2) + } + a.allowUnsigned = strings.EqualFold(os.Getenv("PLUGIN_ALLOW_UNSIGNED"), "true") + if a.allowUnsigned && (environment != "development" || !isLoopbackHost(host)) { + slog.Error("unsigned packages are allowed only in development on loopback") + os.Exit(2) + } + if key, keyErr := deriveConfigKey(os.Getenv("PLUGIN_CONFIG_KEY")); keyErr != nil { + slog.Error("invalid config encryption key", "error", keyErr) + os.Exit(2) + } else { + a.configKey = key + } + a.frameAncestors = parseFrameAncestors(os.Getenv("PLUGIN_FRAME_ANCESTORS")) + a.trustedPublishers = loadTrustedPublishers(os.Getenv("PLUGIN_TRUSTED_PUBLISHERS")) + if err := a.recoverPlugins(); err != nil { + slog.Error("recover plugins", "error", err) + os.Exit(2) + } + addr := net.JoinHostPort(host, port) + srv := &http.Server{Addr: addr, Handler: a.routes(), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second} + slog.Info("plugin-admin listening", "addr", addr) + stopSignals := make(chan os.Signal, 1) + signal.Notify(stopSignals, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(stopSignals) + go func() { + <-stopSignals + shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + a.shutdown() + }() + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + slog.Error("plugin-admin stopped", "error", err) + os.Exit(1) + } +} + +// Keep strconv linked for manifests that use numeric listen settings in future +// revisions; this also makes the validation helper easy to extend without API churn. +var _ = strconv.IntSize diff --git a/plugins/plugin-admin/main_test.go b/plugins/plugin-admin/main_test.go new file mode 100644 index 0000000..fb57f39 --- /dev/null +++ b/plugins/plugin-admin/main_test.go @@ -0,0 +1,990 @@ +package main + +import ( + "archive/zip" + "bytes" + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "git.awaioi.com/awaioi/sub2api-add/plugins/plugin-admin/internal/manifest" +) + +func testCore(t *testing.T, handler http.Handler) (*coreClient, *httptest.Server) { + t.Helper() + server := httptest.NewServer(handler) + client, err := newCoreClient(server.URL) + if err != nil { + server.Close() + t.Fatal(err) + } + return client, server +} + +func adminSession(a *app) *http.Cookie { + now := time.Now() + id := "session" + a.sessions[id] = session{AccessToken: "ACCESS", RefreshToken: "REFRESH", CSRFToken: "CSRF", User: map[string]any{"id": float64(1), "role": "admin", "email": "admin@example.com"}, CreatedAt: now, LastSeen: now} + a.sessionLocks[id] = &sync.Mutex{} + return &http.Cookie{Name: sessionCookieName, Value: id} +} + +func validPackage(t *testing.T, id string) []byte { + return validPackageVersion(t, id, "1.0.0") +} + +func validPackageVersion(t *testing.T, id, version string) []byte { + t.Helper() + ui := []byte("plugin") + manifestValue := map[string]any{ + "schema_version": 1, + "plugin_id": id, + "name": "Example Plugin", + "version": version, + "core_api_baseline": "sub2api-0.1.183", + "tested_core_versions": []string{"0.1.183"}, + "capabilities": []string{"example.v1"}, + "backend": map[string]any{"health_path": "/healthz", "readiness_path": "/readyz", "listen_env": "PLUGIN_PORT"}, + "ui": map[string]any{"entrypoint": "ui/index.html", "menu": map[string]any{"id": id, "label": "Example", "visibility": "admin", "sort_order": 200}}, + "publisher": map[string]any{"key_id": "dev"}, + "core_api_allowlist": []string{"POST /api/v1/auth/login", "POST /api/v1/auth/login/2fa", "POST /api/v1/auth/refresh", "POST /api/v1/auth/logout", "GET /api/v1/auth/me", "GET /api/v1/settings/public"}, + } + manifestValue["files"] = map[string]string{"ui/index.html": sha256Hex(ui)} + manifestBytes, err := json.Marshal(manifestValue) + if err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for name, data := range map[string][]byte{"manifest.json": manifestBytes, "ui/index.html": ui} { + w, err := zw.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(data); err != nil { + t.Fatal(err) + } + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func signedPackage(t *testing.T, id, version string) ([]byte, ed25519.PublicKey) { + t.Helper() + raw := validPackageVersion(t, id, version) + zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) + if err != nil { + t.Fatal(err) + } + entries := make(map[string][]byte, len(zr.File)) + var manifestBytes []byte + for _, file := range zr.File { + reader, openErr := file.Open() + if openErr != nil { + t.Fatal(openErr) + } + data, readErr := io.ReadAll(reader) + _ = reader.Close() + if readErr != nil { + t.Fatal(readErr) + } + entries[file.Name] = data + if file.Name == "manifest.json" { + manifestBytes = data + } + } + publicKey, privateKey, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + signature := manifest.Signature{Algorithm: "ed25519", KeyID: "dev", Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, manifestBytes))} + signatureBytes, err := json.Marshal(signature) + if err != nil { + t.Fatal(err) + } + entries["signature.json"] = signatureBytes + var out bytes.Buffer + zw := zip.NewWriter(&out) + for name, data := range entries { + writer, createErr := zw.Create(name) + if createErr != nil { + t.Fatal(createErr) + } + if _, writeErr := writer.Write(data); writeErr != nil { + t.Fatal(writeErr) + } + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + return out.Bytes(), publicKey +} + +func uploadRequest(t *testing.T, path string, cookie *http.Cookie, csrf, idempotency string, archive []byte) *http.Request { + t.Helper() + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("package", "plugin.s2plugin") + if err != nil { + t.Fatal(err) + } + if _, err := part.Write(archive); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, path, &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("X-CSRF-Token", csrf) + req.Header.Set("Idempotency-Key", idempotency) + req.AddCookie(cookie) + return req +} + +func sha256Hex(value []byte) string { + sum := sha256.Sum256(value) + return hex.EncodeToString(sum[:]) +} + +func TestAdminLoginDoesNotExposeCoreTokens(t *testing.T) { + core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/auth/login": + _, _ = io.WriteString(w, `{"code":0,"data":{"access_token":"CORE_ACCESS","refresh_token":"CORE_REFRESH"}}`) + case "/api/v1/auth/me": + _, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin","email":"admin@example.com","access_token":"LEAK"}}`) + default: + _, _ = io.WriteString(w, `{"code":0,"data":{}}`) + } + })) + defer coreServer.Close() + reg, err := openRegistry(t.TempDir()) + if err != nil { + t.Fatal(err) + } + a := newApp(core, reg, t.TempDir()) + request := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"email":"admin@example.com","password":"password"}`)) + recorder := httptest.NewRecorder() + a.login(recorder, request) + if recorder.Code != http.StatusOK || strings.Contains(recorder.Body.String(), "CORE_ACCESS") || strings.Contains(recorder.Body.String(), "CORE_REFRESH") || strings.Contains(recorder.Body.String(), "LEAK") { + t.Fatalf("unexpected login response: %d %s", recorder.Code, recorder.Body.String()) + } + if len(recorder.Result().Cookies()) != 1 || !recorder.Result().Cookies()[0].HttpOnly { + t.Fatalf("expected HttpOnly plugin cookie: %#v", recorder.Result().Cookies()) + } +} + +func TestDecodeJSONRejectsTrailingValuesAndOversizeBodies(t *testing.T) { + var input struct { + Name string `json:"name"` + } + trailing := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"name":"plugin"}{}`)) + if err := decodeJSON(trailing, &input, 1<<20); err == nil { + t.Fatal("expected concatenated JSON to be rejected") + } + oversized := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"name":"plugin"}`)) + if err := decodeJSON(oversized, &input, 4); err == nil { + t.Fatal("expected oversized JSON body to be rejected") + } +} + +func TestOrdinaryCoreUserIsRejected(t *testing.T) { + core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api/v1/auth/login" { + _, _ = io.WriteString(w, `{"code":0,"data":{"access_token":"A","refresh_token":"R"}}`) + return + } + _, _ = io.WriteString(w, `{"code":0,"data":{"id":2,"role":"user"}}`) + })) + defer coreServer.Close() + reg, _ := openRegistry(t.TempDir()) + a := newApp(core, reg, t.TempDir()) + recorder := httptest.NewRecorder() + a.login(recorder, httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"email":"user@example.com","password":"password"}`))) + if recorder.Code != http.StatusForbidden { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestPackageInspectionAndAtomicInstall(t *testing.T) { + t.Setenv("CORE_VERSION", "0.1.183") + reg, err := openRegistry(t.TempDir()) + if err != nil { + t.Fatal(err) + } + a := newApp(nil, reg, filepath.Dir(reg.path)) + a.allowUnsigned = true + raw := validPackage(t, "example.plugin") + info, err := a.inspectPackage(raw) + if err != nil { + t.Fatal(err) + } + p, err := a.installPackage(info) + if err != nil { + t.Fatal(err) + } + if p.State != "disabled" || p.ActiveRevision == "" { + t.Fatalf("unexpected installed record: %#v", p) + } + if _, err := os.Stat(filepath.Join(p.Revisions[0].Path, "ui", "index.html")); err != nil { + t.Fatal(err) + } + if _, err := a.inspectPackage(bytes.Replace(raw, []byte("ui/index.html"), []byte("../secret"), 1)); err == nil { + t.Fatal("expected invalid package") + } +} + +func TestProductionPackageRequiresTrustedSignature(t *testing.T) { + t.Setenv("CORE_VERSION", "0.1.183") + reg, _ := openRegistry(t.TempDir()) + a := newApp(nil, reg, t.TempDir()) + a.allowUnsigned = false + if _, err := a.inspectPackage(validPackage(t, "unsigned.plugin")); err == nil { + t.Fatal("expected unsigned package rejection") + } + raw, publicKey := signedPackage(t, "signed.plugin", "1.0.0") + a.trustedPublishers = map[string][]byte{"dev": publicKey} + if _, err := a.inspectPackage(raw); err != nil { + t.Fatalf("trusted signed package rejected: %v", err) + } + a.trustedPublishers = map[string][]byte{} + if _, err := a.inspectPackage(raw); err == nil { + t.Fatal("expected untrusted publisher rejection") + } +} + +func TestDuplicateInstallCannotReplaceActiveRevision(t *testing.T) { + t.Setenv("CORE_VERSION", "0.1.183") + reg, _ := openRegistry(t.TempDir()) + a := newApp(nil, reg, t.TempDir()) + a.allowUnsigned = true + first, err := a.installPackage(a.packageForTest(t, "duplicate.plugin", "1.0.0")) + if err != nil { + t.Fatal(err) + } + secondInfo := a.packageForTest(t, "duplicate.plugin", "2.0.0") + if _, err := a.installPackage(secondInfo); err == nil || !strings.Contains(err.Error(), "already installed") { + t.Fatalf("expected duplicate install rejection, got %v", err) + } + reg.mu.Lock() + current := reg.data.Plugins["duplicate.plugin"] + reg.mu.Unlock() + if current.ActiveRevision != first.ActiveRevision || current.Manifest.Version != "1.0.0" { + t.Fatalf("duplicate install replaced active revision: %#v", current) + } +} + +func TestConfigIsEncryptedAndSecretsAreNotReturned(t *testing.T) { + core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`) + })) + defer coreServer.Close() + reg, _ := openRegistry(t.TempDir()) + a := newApp(core, reg, t.TempDir()) + p := pluginRecord{Manifest: manifest.Manifest{PluginID: "example.plugin", Name: "Example", Version: "1.0.0"}, State: "disabled", Revisions: []revision{}} + reg.data.Plugins[p.Manifest.PluginID] = p + now := time.Now() + a.sessions["sid"] = session{AccessToken: "A", RefreshToken: "R", CSRFToken: "CSRF", User: map[string]any{"id": float64(1), "role": "admin"}, CreatedAt: now, LastSeen: now} + a.sessionLocks["sid"] = &sync.Mutex{} + req := httptest.NewRequest(http.MethodPut, "/api/plugins/example.plugin/config", strings.NewReader(`{"service_url":"http://127.0.0.1:18090","client_secret":"TOP-SECRET"}`)) + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"}) + req.Header.Set("X-CSRF-Token", "CSRF") + req.Header.Set("Idempotency-Key", "config-1") + rec := httptest.NewRecorder() + a.config(rec, req) + if rec.Code != http.StatusAccepted || strings.Contains(rec.Body.String(), "TOP-SECRET") { + t.Fatalf("config response leaked secret: %d %s", rec.Code, rec.Body.String()) + } + reg.mu.Lock() + stored := reg.data.Plugins[p.Manifest.PluginID].ConfigCipher + reg.mu.Unlock() + if stored == "" || strings.Contains(stored, "TOP-SECRET") { + t.Fatalf("config was not encrypted: %q", stored) + } + get := httptest.NewRequest(http.MethodGet, "/api/plugins/example.plugin/config", nil) + get.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"}) + getRec := httptest.NewRecorder() + a.config(getRec, get) + if getRec.Code != http.StatusOK || strings.Contains(getRec.Body.String(), "TOP-SECRET") || !strings.Contains(getRec.Body.String(), "configured") { + t.Fatalf("config metadata response: %d %s", getRec.Code, getRec.Body.String()) + } +} + +func TestMutationRequiresCSRFAndIdempotency(t *testing.T) { + core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`) + })) + defer coreServer.Close() + reg, _ := openRegistry(t.TempDir()) + a := newApp(core, reg, t.TempDir()) + a.sessions["sid"] = session{AccessToken: "A", RefreshToken: "R", CSRFToken: "CSRF", User: map[string]any{"id": float64(1), "role": "admin"}, CreatedAt: time.Now(), LastSeen: time.Now()} + a.sessionLocks["sid"] = &sync.Mutex{} + req := httptest.NewRequest(http.MethodPost, "/api/plugins/nope/enable", nil) + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"}) + req.Header.Set("Idempotency-Key", "enable-1") + rec := httptest.NewRecorder() + a.enable(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("missing csrf status=%d body=%s", rec.Code, rec.Body.String()) + } + req = httptest.NewRequest(http.MethodPost, "/api/plugins/nope/enable", nil) + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"}) + req.Header.Set("X-CSRF-Token", "CSRF") + rec = httptest.NewRecorder() + a.enable(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("missing idempotency status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestIdempotencyKeyRejectsDifferentOperationHash(t *testing.T) { + core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`) + })) + defer coreServer.Close() + reg, _ := openRegistry(t.TempDir()) + a := newApp(core, reg, t.TempDir()) + a.sessions["sid"] = session{AccessToken: "A", RefreshToken: "R", CSRFToken: "CSRF", User: map[string]any{"id": float64(1), "role": "admin"}, CreatedAt: time.Now(), LastSeen: time.Now()} + a.sessionLocks["sid"] = &sync.Mutex{} + first := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/enable", strings.NewReader(`{"payload":"a"}`)) + first.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"}) + first.Header.Set("X-CSRF-Token", "CSRF") + first.Header.Set("Idempotency-Key", "same-key") + op, _, ok := a.mutationAuth(httptest.NewRecorder(), first, "enable", "example.plugin") + if !ok || op.ID == "" { + t.Fatal("first operation was not allocated") + } + second := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/enable", strings.NewReader(`{"payload":"b"}`)) + second.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"}) + second.Header.Set("X-CSRF-Token", "CSRF") + second.Header.Set("Idempotency-Key", "same-key") + rec := httptest.NewRecorder() + _, _, ok = a.mutationAuth(rec, second, "enable", "example.plugin") + if ok || rec.Code != http.StatusConflict { + t.Fatalf("expected idempotency conflict: status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestIdempotencyKeyReplaysSameBodyAndRetainsFailedOperation(t *testing.T) { + core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`) + })) + defer coreServer.Close() + reg, _ := openRegistry(t.TempDir()) + a := newApp(core, reg, t.TempDir()) + a.sessions["sid"] = session{AccessToken: "A", RefreshToken: "R", CSRFToken: "CSRF", User: map[string]any{"id": float64(1), "role": "admin"}, CreatedAt: time.Now(), LastSeen: time.Now()} + a.sessionLocks["sid"] = &sync.Mutex{} + newRequest := func() *http.Request { + req := httptest.NewRequest(http.MethodPut, "/api/plugins/example.plugin/config", strings.NewReader(`{"service_url":"http://127.0.0.1:18090"}`)) + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"}) + req.Header.Set("X-CSRF-Token", "CSRF") + req.Header.Set("Idempotency-Key", "config-same") + return req + } + first, _, ok := a.mutationAuth(httptest.NewRecorder(), newRequest(), "config", "example.plugin") + if !ok { + t.Fatal("first operation was not allocated") + } + if _, err := a.registry.finishOperation(first, errors.New("expected failure")); err != nil { + t.Fatal(err) + } + secondRecorder := httptest.NewRecorder() + _, _, ok = a.mutationAuth(secondRecorder, newRequest(), "config", "example.plugin") + if ok || secondRecorder.Code != http.StatusAccepted || !strings.Contains(secondRecorder.Body.String(), first.ID) || !strings.Contains(secondRecorder.Body.String(), `"failed"`) { + t.Fatalf("expected failed operation replay: status=%d body=%s", secondRecorder.Code, secondRecorder.Body.String()) + } +} + +func TestRefreshRevalidatesAdminRole(t *testing.T) { + var meCalls int + core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/auth/me": + meCalls++ + if meCalls == 1 { + w.WriteHeader(http.StatusUnauthorized) + _, _ = io.WriteString(w, `{"code":401,"message":"expired"}`) + return + } + _, _ = io.WriteString(w, `{"code":0,"data":{"id":2,"role":"user"}}`) + case "/api/v1/auth/refresh": + _, _ = io.WriteString(w, `{"code":0,"data":{"access_token":"NEW","refresh_token":"NEW-R"}}`) + case "/api/v1/auth/logout": + _, _ = io.WriteString(w, `{"code":0,"data":{}}`) + default: + _, _ = io.WriteString(w, `{"code":0,"data":{}}`) + } + })) + defer coreServer.Close() + reg, _ := openRegistry(t.TempDir()) + a := newApp(core, reg, t.TempDir()) + now := time.Now() + a.sessions["sid"] = session{AccessToken: "OLD", RefreshToken: "R", CSRFToken: "CSRF", User: map[string]any{"id": float64(1), "role": "admin"}, CreatedAt: now, LastSeen: now} + a.sessionLocks["sid"] = &sync.Mutex{} + req := httptest.NewRequest(http.MethodGet, "/api/me", nil) + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"}) + rec := httptest.NewRecorder() + a.me(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("expected demoted user rejection: status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestHealthProbeRejectsRedirectAndRequiresReadiness(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/healthz" { + http.Redirect(w, r, "http://127.0.0.1:1/internal", http.StatusFound) + return + } + _, _ = io.WriteString(w, `{"status":"ready","version":"1.0.0"}`) + })) + defer server.Close() + reg, _ := openRegistry(t.TempDir()) + a := newApp(nil, reg, t.TempDir()) + p := pluginRecord{Manifest: manifest.Manifest{PluginID: "example.plugin", Version: "1.0.0", Backend: manifest.Backend{HealthPath: "/healthz", ReadinessPath: "/readyz"}}, Endpoint: server.URL} + if err := a.checkPluginHealth(&p); err == nil { + t.Fatal("expected redirecting health endpoint to fail closed") + } +} + +func TestRoutesSetSecurityHeadersAndProtectAPI(t *testing.T) { + reg, _ := openRegistry(t.TempDir()) + a := newApp(nil, reg, t.TempDir()) + server := httptest.NewServer(a.routes()) + defer server.Close() + response, err := server.Client().Get(server.URL + "/api/plugins") + if err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusUnauthorized || response.Header.Get("Content-Security-Policy") == "" || response.Header.Get("X-Content-Type-Options") != "nosniff" { + t.Fatalf("status=%d headers=%v", response.StatusCode, response.Header) + } +} + +func TestMenuPreviewAndApplyPreserveOtherMenuItems(t *testing.T) { + var applied []byte + core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/auth/me": + _, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`) + case "/api/v1/admin/settings": + if r.Method == http.MethodPut { + applied, _ = io.ReadAll(r.Body) + } + _, _ = io.WriteString(w, `{"code":0,"data":{"custom_menu_items":[{"id":"core.home","label":"首页"}]}}`) + default: + _, _ = io.WriteString(w, `{"code":0,"data":{}}`) + } + })) + defer coreServer.Close() + reg, _ := openRegistry(t.TempDir()) + a := newApp(core, reg, t.TempDir()) + a.sessions["sid"] = session{AccessToken: "ACCESS", RefreshToken: "REFRESH", CSRFToken: "CSRF", User: map[string]any{"id": float64(1), "role": "admin"}, CreatedAt: time.Now(), LastSeen: time.Now()} + a.sessionLocks["sid"] = &sync.Mutex{} + menuURL := "http://127.0.0.1:18091" + reg.data.Plugins["example.plugin"] = pluginRecord{Manifest: manifest.Manifest{PluginID: "example.plugin", Name: "Example", Version: "1.0.0", UI: manifest.UI{Entrypoint: "ui/index.html", Menu: manifest.Menu{ID: "example.plugin", Label: "示例插件", Visibility: "admin", SortOrder: 200, URL: menuURL}}}, State: "healthy", ActiveRevision: "rev-1"} + cookie := &http.Cookie{Name: sessionCookieName, Value: "sid"} + preview := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/menu-preview", nil) + preview.AddCookie(cookie) + preview.Header.Set("X-CSRF-Token", "CSRF") + preview.Header.Set("Idempotency-Key", "menu-preview-1") + previewRec := httptest.NewRecorder() + a.menu(previewRec, preview, false) + if previewRec.Code != http.StatusOK || !strings.Contains(previewRec.Body.String(), "core.home") || !strings.Contains(previewRec.Body.String(), "example.plugin") { + t.Fatalf("unexpected menu preview: %d %s", previewRec.Code, previewRec.Body.String()) + } + global := httptest.NewRequest(http.MethodPost, "/api/menu-items/preview", strings.NewReader(`{"plugin_id":"example.plugin"}`)) + global.AddCookie(cookie) + global.Header.Set("X-CSRF-Token", "CSRF") + global.Header.Set("Idempotency-Key", "global-menu-preview-1") + globalRec := httptest.NewRecorder() + a.menuGlobal(globalRec, global, false) + if globalRec.Code != http.StatusOK || !strings.Contains(globalRec.Body.String(), "example.plugin") { + t.Fatalf("unexpected global menu preview: %d %s", globalRec.Code, globalRec.Body.String()) + } + apply := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/menu-apply", nil) + apply.AddCookie(cookie) + apply.Header.Set("X-CSRF-Token", "CSRF") + apply.Header.Set("Idempotency-Key", "menu-apply-1") + applyRec := httptest.NewRecorder() + a.menu(applyRec, apply, true) + if applyRec.Code != http.StatusAccepted || len(applied) == 0 || !strings.Contains(string(applied), "core.home") || !strings.Contains(string(applied), "example.plugin") { + t.Fatalf("unexpected menu apply: %d body=%s request=%s", applyRec.Code, applyRec.Body.String(), applied) + } +} + +func TestFailedUpgradeKeepsActiveRevision(t *testing.T) { + t.Setenv("CORE_VERSION", "0.1.183") + pluginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/healthz" || r.URL.Path == "/readyz" { + _, _ = io.WriteString(w, `{"status":"ok","version":"1.0.0"}`) + return + } + http.NotFound(w, r) + })) + defer pluginServer.Close() + core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`) + })) + defer coreServer.Close() + reg, _ := openRegistry(t.TempDir()) + a := newApp(core, reg, t.TempDir()) + a.allowUnsigned = true + old, err := a.installPackage(a.packageForTest(t, "example.plugin", "1.0.0")) + if err != nil { + t.Fatal(err) + } + old.Endpoint = pluginServer.URL + old.State = "healthy" + reg.mu.Lock() + reg.data.Plugins["example.plugin"] = old + if err := reg.saveLocked(); err != nil { + reg.mu.Unlock() + t.Fatal(err) + } + reg.mu.Unlock() + a.sessions["sid"] = session{AccessToken: "ACCESS", RefreshToken: "REFRESH", CSRFToken: "CSRF", User: map[string]any{"id": float64(1), "role": "admin"}, CreatedAt: time.Now(), LastSeen: time.Now()} + a.sessionLocks["sid"] = &sync.Mutex{} + req := uploadRequest(t, "/api/plugins/example.plugin/upgrade", &http.Cookie{Name: sessionCookieName, Value: "sid"}, "CSRF", "upgrade-1", validPackageVersion(t, "example.plugin", "2.0.0")) + rec := httptest.NewRecorder() + a.install(rec, req, true, "example.plugin") + if rec.Code != http.StatusAccepted || !strings.Contains(rec.Body.String(), "operation_id") { + t.Fatalf("unexpected upgrade response: %d %s", rec.Code, rec.Body.String()) + } + reg.mu.Lock() + current := reg.data.Plugins["example.plugin"] + reg.mu.Unlock() + if current.ActiveRevision != old.ActiveRevision || current.PendingRevision == "" || current.State != "rollback_pending" || current.Manifest.Version != "1.0.0" { + t.Fatalf("active revision changed after failed upgrade: %#v", current) + } + pendingID := current.PendingRevision + var pendingPath string + for _, rev := range current.Revisions { + if rev.ID == pendingID { + pendingPath = rev.Path + } + } + if pendingPath == "" { + t.Fatal("failed upgrade did not retain pending revision path") + } + rollback := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/rollback", nil) + rollback.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"}) + rollback.Header.Set("X-CSRF-Token", "CSRF") + rollback.Header.Set("Idempotency-Key", "rollback-after-failure") + rollbackRec := httptest.NewRecorder() + a.rollback(rollbackRec, rollback) + if rollbackRec.Code != http.StatusAccepted { + t.Fatalf("rollback failed: %d %s", rollbackRec.Code, rollbackRec.Body.String()) + } + reg.mu.Lock() + restored := reg.data.Plugins["example.plugin"] + reg.mu.Unlock() + if restored.ActiveRevision != old.ActiveRevision || restored.PendingRevision != "" || restored.State != "healthy" || restored.Manifest.Version != "1.0.0" { + t.Fatalf("failed-upgrade rollback did not restore old revision: %#v", restored) + } + var retired bool + for _, rev := range restored.Revisions { + if rev.ID == pendingID { + retired = rev.Retired + } + } + if !retired { + t.Fatal("failed candidate was not marked retired") + } + if _, err := os.Stat(pendingPath); err != nil { + t.Fatalf("retired candidate path was removed before registry commit: %v", err) + } +} + +func TestLifecycleEnableDisableUninstall(t *testing.T) { + t.Setenv("CORE_VERSION", "0.1.183") + var applied []byte + pluginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/healthz" || r.URL.Path == "/readyz" { + _, _ = io.WriteString(w, `{"status":"ok","version":"1.0.0"}`) + return + } + http.NotFound(w, r) + })) + defer pluginServer.Close() + core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/auth/me": + _, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`) + case "/api/v1/admin/settings": + if r.Method == http.MethodPut { + applied, _ = io.ReadAll(r.Body) + } + _, _ = io.WriteString(w, `{"code":0,"data":{"custom_menu_items":[{"id":"core.home","label":"首页"},{"id":"example.plugin","label":"示例"}]}}`) + default: + _, _ = io.WriteString(w, `{"code":0,"data":{}}`) + } + })) + defer coreServer.Close() + reg, _ := openRegistry(t.TempDir()) + a := newApp(core, reg, t.TempDir()) + a.allowUnsigned = true + p, err := a.installPackage(a.packageForTest(t, "example.plugin", "1.0.0")) + if err != nil { + t.Fatal(err) + } + p.Endpoint = pluginServer.URL + reg.mu.Lock() + reg.data.Plugins[p.Manifest.PluginID] = p + reg.mu.Unlock() + cookie := adminSession(a) + enable := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/enable", nil) + enable.AddCookie(cookie) + enable.Header.Set("X-CSRF-Token", "CSRF") + enable.Header.Set("Idempotency-Key", "enable-lifecycle") + enableRec := httptest.NewRecorder() + a.enable(enableRec, enable) + if enableRec.Code != http.StatusAccepted { + t.Fatalf("enable failed: %d %s", enableRec.Code, enableRec.Body.String()) + } + reg.mu.Lock() + if reg.data.Plugins["example.plugin"].State != "healthy" { + t.Fatalf("plugin did not become healthy: %#v", reg.data.Plugins["example.plugin"]) + } + reg.mu.Unlock() + disable := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/disable", nil) + disable.AddCookie(cookie) + disable.Header.Set("X-CSRF-Token", "CSRF") + disable.Header.Set("Idempotency-Key", "disable-lifecycle") + disableRec := httptest.NewRecorder() + a.disable(disableRec, disable) + if disableRec.Code != http.StatusAccepted || strings.Contains(string(applied), "example.plugin") { + t.Fatalf("disable did not remove own menu: %d body=%s request=%s", disableRec.Code, disableRec.Body.String(), applied) + } + uninstall := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/uninstall", nil) + uninstall.AddCookie(cookie) + uninstall.Header.Set("X-CSRF-Token", "CSRF") + uninstall.Header.Set("Idempotency-Key", "uninstall-lifecycle") + uninstallRec := httptest.NewRecorder() + a.uninstall(uninstallRec, uninstall) + if uninstallRec.Code != http.StatusAccepted { + t.Fatalf("uninstall failed: %d %s", uninstallRec.Code, uninstallRec.Body.String()) + } + reg.mu.Lock() + _, exists := reg.data.Plugins["example.plugin"] + reg.mu.Unlock() + if exists { + t.Fatal("plugin remained in registry after uninstall") + } +} + +func TestUninstallRegistryFailureRestoresRevisionPath(t *testing.T) { + t.Setenv("CORE_VERSION", "0.1.183") + root := t.TempDir() + registryDir := t.TempDir() + reg, err := openRegistry(registryDir) + if err != nil { + t.Fatal(err) + } + pluginID := "restore.plugin" + revisionPath := filepath.Join(root, "installed", pluginID, "rev-1") + if err := os.MkdirAll(revisionPath, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(revisionPath, "marker"), []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + reg.data.Plugins[pluginID] = pluginRecord{ + Manifest: manifest.Manifest{ + PluginID: pluginID, + Name: "Restore", + Version: "1.0.0", + CoreAPIBaseline: "sub2api-0.1.183", + TestedCoreVersions: []string{"0.1.183"}, + Backend: manifest.Backend{HealthPath: "/healthz", ReadinessPath: "/readyz", ListenEnv: "PLUGIN_PORT"}, + UI: manifest.UI{Entrypoint: "ui/index.html", Menu: manifest.Menu{ID: pluginID, Label: "Restore", Visibility: "admin", SortOrder: 200, URL: "http://127.0.0.1:8090"}}, + }, + State: "disabled", + ActiveRevision: "rev-1", + Revisions: []revision{{ID: "rev-1", Version: "1.0.0", Path: revisionPath}}, + UpdatedAt: time.Now().UTC(), + } + if err := reg.save(); err != nil { + t.Fatal(err) + } + core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/auth/me": + _, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`) + case "/api/v1/admin/settings": + if r.Method == http.MethodPut { + // Force the lifecycle registry commit to fail after the menu + // update, exercising path restoration as well as record rollback. + reg.path = t.TempDir() + } + _, _ = io.WriteString(w, `{"code":0,"data":{"custom_menu_items":[{"id":"`+pluginID+`","label":"Restore"}]}}`) + default: + _, _ = io.WriteString(w, `{"code":0,"data":{}}`) + } + })) + defer coreServer.Close() + a := newApp(core, reg, root) + cookie := adminSession(a) + req := httptest.NewRequest(http.MethodPost, "/api/plugins/"+pluginID+"/uninstall", nil) + req.AddCookie(cookie) + req.Header.Set("X-CSRF-Token", "CSRF") + req.Header.Set("Idempotency-Key", "uninstall-restore") + rec := httptest.NewRecorder() + a.uninstall(rec, req) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected persistence failure, got %d body=%s", rec.Code, rec.Body.String()) + } + reg.mu.Lock() + restored, exists := reg.data.Plugins[pluginID] + reg.mu.Unlock() + if !exists || len(restored.Revisions) != 1 || restored.Revisions[0].Path != revisionPath { + t.Fatalf("registry record was not restored: exists=%v record=%#v", exists, restored) + } + if _, err := os.Stat(filepath.Join(revisionPath, "marker")); err != nil { + t.Fatalf("revision path was not restored: %v", err) + } +} + +func TestPluginLockSerializesLifecycleMutations(t *testing.T) { + reg, _ := openRegistry(t.TempDir()) + a := newApp(nil, reg, t.TempDir()) + firstEntered := make(chan struct{}) + release := make(chan struct{}) + secondEntered := make(chan struct{}) + go func() { + unlock := a.lockPlugin("example.plugin") + close(firstEntered) + <-release + unlock() + }() + <-firstEntered + go func() { + unlock := a.lockPlugin("example.plugin") + close(secondEntered) + unlock() + }() + select { + case <-secondEntered: + t.Fatal("second plugin mutation acquired the lock concurrently") + case <-time.After(25 * time.Millisecond): + } + close(release) + select { + case <-secondEntered: + case <-time.After(time.Second): + t.Fatal("second plugin mutation did not proceed after release") + } +} + +func TestRecoverExternalPluginAfterRestart(t *testing.T) { + t.Setenv("CORE_VERSION", "0.1.183") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/healthz" && r.URL.Path != "/readyz" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"status":"ok","version":"1.0.0"}`) + })) + defer server.Close() + reg, _ := openRegistry(t.TempDir()) + a := newApp(nil, reg, t.TempDir()) + p := pluginRecord{Manifest: manifest.Manifest{PluginID: "external.plugin", Name: "External", Version: "1.0.0", CoreAPIBaseline: "sub2api-0.1.183", TestedCoreVersions: []string{"0.1.183"}, Backend: manifest.Backend{HealthPath: "/healthz", ReadinessPath: "/readyz"}}, State: "healthy", ActiveRevision: "rev-1", Endpoint: server.URL, Revisions: []revision{{ID: "rev-1", Version: "1.0.0", Manifest: manifest.Manifest{PluginID: "external.plugin", Name: "External", Version: "1.0.0", CoreAPIBaseline: "sub2api-0.1.183", TestedCoreVersions: []string{"0.1.183"}, Backend: manifest.Backend{HealthPath: "/healthz", ReadinessPath: "/readyz"}}}}} + reg.data.Plugins[p.Manifest.PluginID] = p + if err := reg.save(); err != nil { + t.Fatal(err) + } + if err := a.recoverPlugins(); err != nil { + t.Fatal(err) + } + reg.mu.Lock() + recovered := reg.data.Plugins[p.Manifest.PluginID] + reg.mu.Unlock() + if recovered.State != "healthy" || recovered.Endpoint != server.URL || recovered.LastError != "" { + t.Fatalf("external plugin was not recovered: %#v", recovered) + } +} + +func TestRecoverCommandPluginAfterRestart(t *testing.T) { + t.Setenv("CORE_VERSION", "0.1.183") + if _, err := os.Stat("/bin/sh"); err != nil { + t.Skip("shell is unavailable") + } + root := t.TempDir() + pluginDir := filepath.Join(root, "installed", "command.plugin", "rev-1") + if err := os.MkdirAll(filepath.Join(pluginDir, "service"), 0o700); err != nil { + t.Fatal(err) + } + // The helper is a tiny Python HTTP server available in the local test + // environment; it binds the supervisor-provided loopback port. + command := filepath.Join(pluginDir, "service", "run.py") + source := "#!/usr/bin/env python3\nimport http.server, os\nclass H(http.server.BaseHTTPRequestHandler):\n def do_GET(self):\n if self.path in ('/healthz','/readyz'):\n body=b'{\\\"status\\\":\\\"ok\\\",\\\"version\\\":\\\"1.0.0\\\"}'\n self.send_response(200); self.send_header('Content-Type','application/json'); self.send_header('Content-Length',str(len(body))); self.end_headers(); self.wfile.write(body)\n else: self.send_response(404); self.end_headers()\n def log_message(self,*args): pass\nhttp.server.HTTPServer(('127.0.0.1', int(os.environ['PLUGIN_PORT'])), H).serve_forever()\n" + if err := os.WriteFile(command, []byte(source), 0o700); err != nil { + t.Fatal(err) + } + pythonPath, err := exec.LookPath("python3") + if err != nil { + t.Skip("python3 is unavailable") + } + source = strings.Replace(source, "#!/usr/bin/env python3", "#!"+pythonPath, 1) + reg, _ := openRegistry(filepath.Join(root, "registry")) + pluginManifest := manifest.Manifest{PluginID: "command.plugin", Name: "Command", Version: "1.0.0", CoreAPIBaseline: "sub2api-0.1.183", TestedCoreVersions: []string{"0.1.183"}, Backend: manifest.Backend{HealthPath: "/healthz", ReadinessPath: "/readyz", Command: "service/run.py", ListenEnv: "PLUGIN_PORT"}} + reg.data.Plugins[pluginManifest.PluginID] = pluginRecord{Manifest: pluginManifest, State: "healthy", ActiveRevision: "rev-1", Revisions: []revision{{ID: "rev-1", Version: "1.0.0", Path: pluginDir, Manifest: pluginManifest}}} + if err := reg.save(); err != nil { + t.Fatal(err) + } + a := newApp(nil, reg, root) + if err := a.recoverPlugins(); err != nil { + t.Fatal(err) + } + reg.mu.Lock() + recovered := reg.data.Plugins[pluginManifest.PluginID] + reg.mu.Unlock() + if recovered.State != "healthy" || !strings.HasPrefix(recovered.Endpoint, "http://127.0.0.1:") { + t.Fatalf("command plugin was not recovered: %#v", recovered) + } + a.stopPlugin(pluginManifest.PluginID) +} + +func TestRegistryPersistenceErrorsAreObservable(t *testing.T) { + reg, _ := openRegistry(t.TempDir()) + reg.path = t.TempDir() + if _, _, _, err := reg.operation("enable", "example.plugin", "key", 1, "rid", "hash"); err == nil { + t.Fatal("expected operation persistence error") + } + if len(reg.data.Operations) != 0 { + t.Fatal("failed operation allocation remained in memory") + } + reg.path = filepath.Join(t.TempDir(), "registry.json") + op, _, _, err := reg.operation("enable", "example.plugin", "key", 1, "rid", "hash") + if err != nil { + t.Fatal(err) + } + reg.path = t.TempDir() + if _, err := reg.finishOperation(op, nil); err == nil { + t.Fatal("expected operation finish persistence error") + } + reg.data.Audit = nil + if err := reg.addAudit(auditEvent{Action: "test"}); err == nil { + t.Fatal("expected audit persistence error") + } +} + +func TestUpgradeDoesNotCarryEndpointAcrossServiceModes(t *testing.T) { + reg, _ := openRegistry(t.TempDir()) + a := newApp(nil, reg, t.TempDir()) + base := manifest.Manifest{PluginID: "mode.plugin", Name: "Mode", Version: "1.0.0", CoreAPIBaseline: "sub2api-0.1.183", TestedCoreVersions: []string{"0.1.183"}, Backend: manifest.Backend{HealthPath: "/healthz", ReadinessPath: "/readyz", ListenEnv: "PLUGIN_PORT", Command: "service/plugin"}} + old := pluginRecord{Manifest: base, State: "disabled", ActiveRevision: "old", Endpoint: "http://127.0.0.1:59001", Revisions: []revision{{ID: "old", Version: "1.0.0", Manifest: base}}} + reg.data.Plugins[base.PluginID] = old + newManifest := base + newManifest.Version = "2.0.0" + newManifest.Backend.Command = "" + newInfo := packageInfo{Manifest: newManifest, Archive: []byte("external"), Files: map[string][]byte{"ui/index.html": []byte("mode")}} + newManifest.Files = map[string]string{"ui/index.html": sha256Hex(newInfo.Files["ui/index.html"])} + newInfo.Manifest = newManifest + upgraded, err := a.installPackageMode(newInfo, true) + if err != nil { + t.Fatal(err) + } + if upgraded.Endpoint != "" { + t.Fatalf("command endpoint leaked into external revision: %q", upgraded.Endpoint) + } + externalBase := base + externalBase.Backend.Command = "" + externalBase.Version = "2.0.0" + reg.data.Plugins[base.PluginID] = pluginRecord{Manifest: externalBase, State: "disabled", ActiveRevision: "external", Endpoint: "http://127.0.0.1:59001", Revisions: []revision{{ID: "external", Version: "2.0.0", Manifest: externalBase}}} + commandManifest := newManifest + commandManifest.Version = "3.0.0" + commandManifest.Backend.Command = "service/plugin" + commandInfo := packageInfo{Manifest: commandManifest, Archive: []byte("command"), Files: map[string][]byte{"service/plugin": []byte("#!/bin/sh\nexit 0"), "ui/index.html": []byte("mode")}} + commandManifest.Files = map[string]string{"service/plugin": sha256Hex(commandInfo.Files["service/plugin"]), "ui/index.html": sha256Hex(commandInfo.Files["ui/index.html"])} + commandInfo.Manifest = commandManifest + commandUpgraded, err := a.installPackageMode(commandInfo, true) + if err != nil { + t.Fatal(err) + } + if commandUpgraded.Endpoint != "" { + t.Fatalf("external endpoint leaked into command revision: %q", commandUpgraded.Endpoint) + } +} + +func TestTwoFactorLoginCreatesAdminSession(t *testing.T) { + var login2FACalled bool + core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/auth/login": + _, _ = io.WriteString(w, `{"code":0,"data":{"requires_2fa":true,"temp_token":"TEMP"}}`) + case "/api/v1/auth/login/2fa": + login2FACalled = true + _, _ = io.WriteString(w, `{"code":0,"data":{"access_token":"ACCESS","refresh_token":"REFRESH"}}`) + case "/api/v1/auth/me": + _, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin","email":"admin@example.com"}}`) + case "/api/v1/auth/logout": + _, _ = io.WriteString(w, `{"code":0,"data":{}}`) + default: + _, _ = io.WriteString(w, `{"code":0,"data":{}}`) + } + })) + defer coreServer.Close() + reg, _ := openRegistry(t.TempDir()) + a := newApp(core, reg, t.TempDir()) + loginRec := httptest.NewRecorder() + a.login(loginRec, httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"email":"admin@example.com","password":"password"}`))) + if loginRec.Code != http.StatusOK || !strings.Contains(loginRec.Body.String(), "pending_token") { + t.Fatalf("expected 2fa challenge: %d %s", loginRec.Code, loginRec.Body.String()) + } + var challenge struct { + PendingToken string `json:"pending_token"` + } + if err := json.Unmarshal(loginRec.Body.Bytes(), &challenge); err != nil || challenge.PendingToken == "" { + t.Fatalf("challenge token missing: %s", loginRec.Body.String()) + } + body := fmt.Sprintf(`{"pending_token":%q,"totp_code":"123456"}`, challenge.PendingToken) + verifyRec := httptest.NewRecorder() + a.login2FA(verifyRec, httptest.NewRequest(http.MethodPost, "/login/2fa", strings.NewReader(body))) + if verifyRec.Code != http.StatusOK || !login2FACalled || len(verifyRec.Result().Cookies()) != 1 { + t.Fatalf("2fa login failed: %d %s", verifyRec.Code, verifyRec.Body.String()) + } +} + +func (a *app) packageForTest(t *testing.T, id, version string) packageInfo { + t.Helper() + raw := validPackageVersion(t, id, version) + info, err := a.inspectPackage(raw) + if err != nil { + t.Fatal(err) + } + return info +} diff --git a/plugins/plugin-admin/test/browser-check.mjs b/plugins/plugin-admin/test/browser-check.mjs new file mode 100644 index 0000000..dfdb704 --- /dev/null +++ b/plugins/plugin-admin/test/browser-check.mjs @@ -0,0 +1,40 @@ +import { chromium } from 'playwright' +import fs from 'node:fs/promises' +import path from 'node:path' + +const origin = process.env.PLUGIN_BROWSER_ORIGIN || 'http://127.0.0.1:18090' +const entry = `${origin}/admin/` +const outputDir = path.resolve(process.env.PLUGIN_SCREENSHOT_DIR || '.playwright-cli/plugin-admin') +const email = process.env.PLUGIN_TEST_EMAIL || 'admin@example.com' +const password = process.env.PLUGIN_TEST_PASSWORD || 'password' + +await fs.mkdir(outputDir, { recursive: true }) +const browser = await chromium.launch({ headless: true }) +try { + for (const width of [425, 900, 1440]) { + const page = await browser.newPage({ viewport: { width, height: 900 }, deviceScaleFactor: 1 }) + const responseLeaks = [] + page.on('response', async (response) => { + if (!response.headers()['content-type']?.includes('application/json')) return + try { + const body = await response.text() + if (/access_token|refresh_token|admin[_-]?api[_-]?key|password|client_secret/i.test(body)) responseLeaks.push(response.url()) + } catch (_) {} + }) + await page.goto(entry, { waitUntil: 'networkidle' }) + await page.getByLabel('邮箱').fill(email) + await page.getByLabel('密码').fill(password) + await page.getByRole('button', { name: '登录' }).click() + await page.getByRole('heading', { name: '已登记插件' }).waitFor() + const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth) + if (overflow) throw new Error(`horizontal overflow at ${width}px`) + const buttons = await page.locator('button, .file-button').evaluateAll((items) => items.filter((item) => item.getClientRects().length > 0 && getComputedStyle(item).visibility !== 'hidden').every((item) => item.getBoundingClientRect().height >= 28 && item.getBoundingClientRect().width >= 28)) + if (!buttons) throw new Error(`control collapsed at ${width}px`) + await page.waitForTimeout(50) + if (responseLeaks.length) throw new Error(`sensitive response field exposed at ${width}px: ${responseLeaks.join(', ')}`) + await page.screenshot({ path: path.join(outputDir, `plugin-admin-${width}.png`), fullPage: true }) + await page.close() + } +} finally { + await browser.close() +} diff --git a/plugins/plugin-admin/test/run-browser-check.sh b/plugins/plugin-admin/test/run-browser-check.sh new file mode 100755 index 0000000..b14e512 --- /dev/null +++ b/plugins/plugin-admin/test/run-browser-check.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env sh +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +PLUGIN_BROWSER_ORIGIN=${PLUGIN_BROWSER_ORIGIN:-http://127.0.0.1:18090} \ +PLUGIN_SCREENSHOT_DIR=${PLUGIN_SCREENSHOT_DIR:-$ROOT/.screenshots/plugin-admin} \ +node "$ROOT/test/browser-check.mjs" diff --git a/plugins/plugin-admin/ui/app.js b/plugins/plugin-admin/ui/app.js new file mode 100644 index 0000000..4590d1a --- /dev/null +++ b/plugins/plugin-admin/ui/app.js @@ -0,0 +1,65 @@ +(() => { + 'use strict' + const base = (window.__PLUGIN_BASE_PATH__ || '').replace(/\/$/, '') + const $ = (selector) => document.querySelector(selector) + let csrf = '' + let pendingToken = '' + let configPluginId = '' + const notice = (message, error = false) => { + const el = $('#notice'); el.textContent = message || ''; el.className = error ? 'notice error' : 'notice' + } + const api = async (path, options = {}) => { + const headers = new Headers(options.headers || {}) + headers.set('Accept', 'application/json') + if (options.body && !(options.body instanceof FormData)) headers.set('Content-Type', 'application/json') + if (csrf && options.method && options.method !== 'GET') headers.set('X-CSRF-Token', csrf) + const response = await fetch(`${base}${path}`, { ...options, headers, credentials: 'same-origin' }) + const data = await response.json().catch(() => ({})) + if (!response.ok) throw new Error(data.error || `请求失败 (${response.status})`) + return data + } + const setLoggedIn = (user) => { + $('#login-panel').hidden = !!user + $('#app-panel').hidden = !user + $('#logout').hidden = !user + $('#operator').textContent = user ? (user.email || user.username || '管理员') : '' + } + const login = async (event) => { + event.preventDefault(); $('#login-error').textContent = '' + const body = Object.fromEntries(new FormData(event.currentTarget).entries()) + try { + const result = await api('/login', { method: 'POST', body: JSON.stringify(body) }) + if (result.requires_2fa) { pendingToken = result.pending_token; $('#login-form').hidden = true; $('#twofa-form').hidden = false; return } + csrf = result.csrf_token; setLoggedIn(result.user); await loadAll() + } catch (error) { $('#login-error').textContent = error.message } + } + const login2fa = async (event) => { + event.preventDefault(); $('#login-error').textContent = '' + const body = Object.fromEntries(new FormData(event.currentTarget).entries()); body.pending_token = pendingToken + try { const result = await api('/login/2fa', { method: 'POST', body: JSON.stringify(body) }); csrf = result.csrf_token; setLoggedIn(result.user); await loadAll() } catch (error) { $('#login-error').textContent = error.message } + } + const logout = async () => { try { await api('/logout', { method: 'POST' }) } catch (_) {} csrf = ''; setLoggedIn(null); $('#login-form').hidden = false; $('#twofa-form').hidden = true } + const badge = (state) => `${escapeHtml(state || 'unknown')}` + const escapeHtml = (value) => String(value == null ? '' : value).replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char])) + const loadPlugins = async () => { + const data = await api('/api/plugins'); const root = $('#plugins'); root.textContent = '' + if (!data.items || !data.items.length) { root.innerHTML = '
还没有登记业务插件
'; return } + for (const plugin of data.items) { + const card = document.createElement('article'); card.className = 'plugin-card' + card.innerHTML = `

${escapeHtml(plugin.name)}

${escapeHtml(plugin.plugin_id)} · v${escapeHtml(plugin.version)}

${badge(plugin.state)}
能力
${(plugin.capabilities || []).map(escapeHtml).join(', ') || '未声明'}
活动 revision
${escapeHtml(plugin.active_revision || '-')}
Core 兼容性
${escapeHtml((plugin.compatibility || {}).status || 'unknown')}

${escapeHtml(plugin.last_error || '')}

` + root.appendChild(card) + } + } + const loadAudit = async () => { const data = await api('/api/audit'); const root = $('#audit'); root.textContent = ''; for (const item of (data.items || []).slice().reverse()) { const row = document.createElement('div'); row.className = 'audit-row'; row.innerHTML = `${escapeHtml(item.action)}${escapeHtml(item.plugin_id || '-')}${escapeHtml(item.result)}`; root.appendChild(row) } } + const loadAll = async () => { try { await Promise.all([loadPlugins(), loadAudit()]); notice('') } catch (error) { notice(error.message, true) } } + const mutate = async (action, id) => { const key = `${action}-${id}-${Date.now()}`; try { const result = await api(`/api/plugins/${encodeURIComponent(id)}/${action}`, { method: 'POST', headers: { 'Idempotency-Key': key } }); notice(`操作已提交:${result.operation_id || result.state}`); await loadAll() } catch (error) { notice(error.message, true) } } + const openConfig = async (id) => { configPluginId = id; $('#config-plugin').textContent = id; $('#config-error').textContent = ''; const form = $('#config-form'); form.elements.service_url.value = ''; form.elements.public_url.value = ''; try { const data = await api(`/api/plugins/${encodeURIComponent(id)}/config`); form.elements.service_url.value = data.endpoint || ''; if (data.config && typeof data.config.public_url === 'string') form.elements.public_url.value = data.config.public_url; $('#config-dialog').showModal() } catch (error) { notice(error.message, true) } } + const saveConfig = async (event) => { event.preventDefault(); const form = event.currentTarget; const body = { service_url: form.elements.service_url.value.trim(), public_url: form.elements.public_url.value.trim() }; try { const result = await api(`/api/plugins/${encodeURIComponent(configPluginId)}/config`, { method: 'PUT', headers: { 'Idempotency-Key': `config-${configPluginId}-${Date.now()}` }, body: JSON.stringify(body) }); $('#config-dialog').close(); notice(`配置已保存:${result.operation_id || result.state}`); await loadAll() } catch (error) { $('#config-error').textContent = error.message } } + const upload = async (file) => { const form = new FormData(); form.append('package', file); try { const result = await api('/api/plugins/install', { method: 'POST', headers: { 'Idempotency-Key': `install-${Date.now()}` }, body: form }); notice(`安装已提交:${result.operation_id || result.state}`); await loadAll() } catch (error) { notice(error.message, true) } } + const uploadUpgrade = async (id, file) => { const form = new FormData(); form.append('package', file); try { const result = await api(`/api/plugins/${encodeURIComponent(id)}/upgrade`, { method: 'POST', headers: { 'Idempotency-Key': `upgrade-${id}-${Date.now()}` }, body: form }); notice(`升级已提交:${result.operation_id || result.state}`); await loadAll() } catch (error) { notice(error.message, true) } } + $('#login-form').addEventListener('submit', login); $('#twofa-form').addEventListener('submit', login2fa); $('#logout').addEventListener('click', logout); $('#refresh').addEventListener('click', loadAll); $('#audit-refresh').addEventListener('click', loadAudit); $('#config-form').addEventListener('submit', saveConfig); $('#config-close').addEventListener('click', () => $('#config-dialog').close()); $('#config-cancel').addEventListener('click', () => $('#config-dialog').close()) + $('#package-file').addEventListener('change', (event) => { const file = event.target.files[0]; if (file) upload(file); event.target.value = '' }) + $('#plugins').addEventListener('change', (event) => { const input = event.target.closest('[data-action="upgrade-file"]'); if (!input) return; const file = input.files[0]; if (file) uploadUpgrade(input.dataset.id, file); input.value = '' }) + $('#plugins').addEventListener('click', (event) => { const button = event.target.closest('[data-action]'); if (!button || button.disabled) return; const action = button.dataset.action; const id = button.dataset.id; if (action === 'config') { openConfig(id); return } mutate(action, id) }) + api('/api/me').then((data) => { csrf = data.csrf_token; setLoggedIn(data.user); loadAll() }).catch(() => setLoggedIn(null)) +})() diff --git a/plugins/plugin-admin/ui/index.html b/plugins/plugin-admin/ui/index.html new file mode 100644 index 0000000..afa9821 --- /dev/null +++ b/plugins/plugin-admin/ui/index.html @@ -0,0 +1,71 @@ + + + + + + 插件管理 + + + +
+
+
+

SUB2API EXTENSIONS

+

插件管理

+

独立业务插件控制面

+
+
+ + +
+
+ +
+

管理员登录

+

使用 Sub2API Core 管理员账号登录。普通账号没有访问权限。

+
+ + + +
+ + +
+ + +
+ +
+

插件配置

+

+ + +

密钥只在服务端加密保存,页面不会回显原值。

+
+ +
+
+ + + + diff --git a/plugins/plugin-admin/ui/styles.css b/plugins/plugin-admin/ui/styles.css new file mode 100644 index 0000000..97f577e --- /dev/null +++ b/plugins/plugin-admin/ui/styles.css @@ -0,0 +1,27 @@ +:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #17202a; background: #f4f6f8; } +* { box-sizing: border-box; } +[hidden] { display: none !important; } +body { margin: 0; min-width: 320px; } +.shell { width: min(1120px, calc(100% - 32px)); margin: 0 auto; padding: 32px 0 56px; } +.topbar, .toolbar, .section-heading, .plugin-title, .card-actions, .top-actions { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.topbar { padding-bottom: 24px; border-bottom: 1px solid #d9dee5; } +.eyebrow { color: #5a6877; font-size: 11px; letter-spacing: .12em; margin: 0 0 6px; } +h1, h2, h3, p { margin-top: 0; } h1 { font-size: 28px; margin-bottom: 4px; } h2 { font-size: 18px; margin-bottom: 8px; } h3 { margin-bottom: 4px; font-size: 17px; } +.muted { color: #687585; font-size: 13px; } .operator { color: #475569; font-size: 13px; } +.panel { background: #fff; border: 1px solid #d9dee5; border-radius: 6px; box-shadow: 0 2px 8px rgb(15 23 42 / 4%); } +.auth-panel { max-width: 480px; margin: 48px auto 0; padding: 24px; } +.form-grid { display: grid; gap: 14px; margin-top: 20px; } label { display: grid; gap: 6px; color: #334155; font-size: 13px; } +input { width: 100%; height: 36px; padding: 0 10px; border: 1px solid #c7d0da; border-radius: 4px; background: #fff; color: inherit; font: inherit; } input:focus { outline: 2px solid #a7c7ff; outline-offset: 1px; } +.button, .file-button { display: inline-flex; align-items: center; justify-content: center; min-height: 36px; padding: 0 14px; border: 1px solid #2563eb; border-radius: 4px; background: #2563eb; color: #fff; cursor: pointer; font: inherit; font-size: 13px; white-space: nowrap; } +.button:hover { background: #1d4ed8; } .button:disabled { opacity: .45; cursor: not-allowed; } .button-secondary { background: #fff; color: #1e40af; border-color: #b9c8dc; } .button-secondary:hover, .button-quiet:hover { background: #f3f6fa; } .button-quiet { background: transparent; color: #334155; border-color: transparent; } .button-danger { background: #fff; color: #b42318; border-color: #e1aaa4; } +.file-button input { display: none; } +#app-panel { margin-top: 32px; } .toolbar { margin-bottom: 18px; } .toolbar-actions { display: flex; gap: 8px; flex-wrap: wrap; } +.notice { min-height: 20px; margin: 8px 0 14px; font-size: 13px; color: #17603a; } .error { color: #b42318; } +.plugin-list { display: grid; gap: 12px; } +.plugin-card { background: #fff; border: 1px solid #d9dee5; border-radius: 6px; padding: 18px; } .plugin-title { align-items: flex-start; } .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; overflow-wrap: anywhere; } +.badge { display: inline-flex; padding: 4px 8px; border-radius: 999px; color: #334155; background: #e8edf2; font-size: 12px; } .badge-healthy { background: #d9f6e5; color: #146c43; } .badge-error, .badge-incompatible { background: #fde1df; color: #9b1c16; } .badge-starting, .badge-draining { background: #fff0c2; color: #7a4d00; } +.meta { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin: 18px 0 12px; } .meta div { min-width: 0; } dt { color: #687585; font-size: 12px; margin-bottom: 4px; } dd { margin: 0; overflow-wrap: anywhere; font-size: 13px; } .plugin-error { min-height: 18px; margin-bottom: 12px; font-size: 12px; color: #b42318; } +.card-actions { justify-content: flex-start; flex-wrap: wrap; } .empty { padding: 32px; text-align: center; color: #687585; border: 1px dashed #c7d0da; border-radius: 6px; background: #fff; } +.audit-panel { margin-top: 24px; padding: 18px; } .audit-list { display: grid; gap: 1px; } .audit-row { display: grid; grid-template-columns: 1.2fr 1.3fr .8fr 1.7fr; gap: 10px; padding: 9px 0; border-top: 1px solid #edf0f3; font-size: 12px; overflow-wrap: anywhere; } +.config-dialog { width: min(460px, calc(100% - 24px)); padding: 0; border: 0; border-radius: 6px; box-shadow: 0 18px 60px rgb(15 23 42 / 24%); } .config-dialog::backdrop { background: rgb(15 23 42 / 42%); } .config-form { display: grid; gap: 14px; padding: 22px; } .config-form .section-heading { margin-bottom: 4px; } .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; } +@media (max-width: 640px) { .shell { width: min(100% - 20px, 560px); padding-top: 20px; } .topbar, .toolbar { align-items: flex-start; flex-direction: column; } .top-actions { width: 100%; justify-content: space-between; } .meta { grid-template-columns: 1fr; gap: 9px; } .card-actions .button, .toolbar-actions .button, .file-button { flex: 1 1 130px; } .audit-row { grid-template-columns: 1fr 1fr; } } diff --git a/plugins/subscription-admin/.env.example b/plugins/subscription-admin/.env.example new file mode 100644 index 0000000..c6e37a9 --- /dev/null +++ b/plugins/subscription-admin/.env.example @@ -0,0 +1,16 @@ +CORE_BASE_URL=http://127.0.0.1:8080 +PLUGIN_HOST=127.0.0.1 +PLUGIN_PORT=8091 +# Optional public path when mounted behind a reverse proxy, e.g. +# /extensions/qiu.subscription-admin +PLUGIN_PUBLIC_BASE_PATH= +# Limit the session cookie to the plugin mount path in production. +PLUGIN_COOKIE_PATH= +# Set true only behind HTTPS. Non-loopback listeners fail closed when false. +PLUGIN_COOKIE_SECURE=false +# lax (same-site proxy), strict, or none (cross-site iframe; requires Secure). +PLUGIN_COOKIE_SAMESITE=lax +# Space-separated frame ancestors. Keep 'self' for same-origin proxying. +PLUGIN_FRAME_ANCESTORS='self' +# Set true only when the immediate reverse proxy is trusted and supplies XFF. +PLUGIN_TRUST_PROXY=false diff --git a/plugins/subscription-admin/Makefile b/plugins/subscription-admin/Makefile new file mode 100644 index 0000000..ead7d28 --- /dev/null +++ b/plugins/subscription-admin/Makefile @@ -0,0 +1,14 @@ +.PHONY: test build package check + +test: + go test ./... -count=1 + +build: + ./build.sh + +package: + ./package.sh + +check: test + node --check ui/app.js + sh -n package.sh diff --git a/plugins/subscription-admin/README.md b/plugins/subscription-admin/README.md new file mode 100644 index 0000000..8864a25 --- /dev/null +++ b/plugins/subscription-admin/README.md @@ -0,0 +1,97 @@ +# Sub2API Subscription Admin Business Plugin V1 + +这是一个独立运行的管理员只读业务插件,不是现有 `.s2plugin` transport 插件,也不是插件管理后台。它不导入 Sub2API `internal` 包,不连接 Core 数据库,也不修改 Core Go/Vue、迁移、路由或 `.s2plugin` ABI。 + +生产/集成环境由通用 `plugins/plugin-admin` 控制面安装、启用和升级本插件;本插件不会预装,也不会成为控制面首页。只有健康检查通过并由管理员执行菜单预览/应用后,Core 管理员菜单才会出现“订阅管理”入口。直接运行本目录仅用于本地开发和契约测试。 + +## 本地启动 + +```sh +CORE_BASE_URL=http://127.0.0.1:8080 \ +PLUGIN_HOST=127.0.0.1 \ +PLUGIN_PORT=8091 \ +go run . +``` + +打开 `http://127.0.0.1:8091/admin/`。生产环境应通过 HTTPS 反向代理,并设置 `PLUGIN_COOKIE_SECURE=true`。挂载到子路径时同时设置 `PLUGIN_PUBLIC_BASE_PATH` 和 `PLUGIN_COOKIE_PATH`,例如 `/extensions/qiu.subscription-admin`。 + +## V1 范围 + +- Core 管理员账号登录和 Core 2FA;普通账号统一拒绝。 +- 插件 HttpOnly、SameSite 会话和写请求 CSRF 校验。 +- Core token 只保存在插件服务端内存会话中,不进入浏览器、URL、HTML、LocalStorage、响应或日志。 +- 只读套餐、订阅列表、订阅详情和插件操作记录。 +- Core access token 失效时最多刷新一次;刷新失败会销毁插件会话。 +- 页面刷新会从插件会话恢复,并重新取得短期 CSRF token;服务端不会把 Core token 返回浏览器。 +- 登录会读取 Core 公开验证码配置并透传 Turnstile、腾讯、阿里云或 GeeTest 的验证结果;验证码本身仍由 Core 校验。 +- 余额购买、续费、撤销、退款和外部支付不在 V1,页面不渲染提交按钮。 + +## Core API allowlist + +插件服务端仅调用这些明确路径: + +```text +POST /api/v1/auth/login +POST /api/v1/auth/login/2fa +POST /api/v1/auth/refresh +POST /api/v1/auth/logout +GET /api/v1/auth/me +GET /api/v1/settings/public +GET /api/v1/admin/payment/plans +GET /api/v1/admin/subscriptions +GET /api/v1/admin/subscriptions/{id} +GET /api/v1/admin/users/{id} +GET /api/v1/admin/users/{id}/subscriptions +``` + +列表请求只接受 `page`、`page_size`、`limit`、`user_id`、`group_id`、`status`、`platform`、`sort_by`、`sort_order` 参数。插件不会代理任意 URL,也不会调用尚不存在的 `/api/v1/plugin-host/*`。 + +## Core 菜单与反向代理 + +控制面会依据清单中的菜单声明生成下面的 `custom_menu_items` 项;部署时无需手工写入订阅菜单: + +```json +{ + "id": "qiu.subscription-admin", + "label": "订阅管理", + "url": "https://CORE_ORIGIN/extensions/qiu.subscription-admin/", + "visibility": "admin", + "sort_order": 200 +} +``` + +Core 自定义页面的 sandbox iframe 不会继承 Core `localStorage` 登录态,因此 V1 首屏显示插件登录页是预期行为;同时提供新窗口入口。不要把 JWT 放进 URL。 + +## 测试 + +```sh +go test ./... -count=1 +node --check ui/app.js +``` + +`main_test.go` 覆盖 Core 路径 allowlist、查询参数过滤、管理员角色拒绝、插件 Cookie、Core token 不泄露、会话过期、请求 ID、登录限流和 2FA pending 一次性消费。浏览器验收脚本位于 `test/browser-check.mjs`,可使用本地 Mock Core 验证直连、子路径反代和三种视口。 + +## 生成可安装包 + +```sh +./package.sh +``` + +脚本生成 `dist/qiu.subscription-admin.s2plugin`,包内根文件名为 +`manifest.json`,并包含清单声明哈希的 UI 文件。该插件采用外部服务模式: +安装后先独立启动 `subscription-admin`,再在 `plugin-admin` 的配置中填写 +`service_url`(插件 loopback 地址)和 `public_url`(反向代理地址),然后执行 +启用、健康检查和菜单应用。生产环境必须把签名文件通过 +`SIGNATURE_FILE=/path/to/signature.json ./package.sh` 放入包内,并将对应公钥 +加入控制面受信发布者配置;未签名包仅限 development + loopback。 + +## 清单和发布 + +`business-plugin-manifest.v1.json` 是部署层清单,不由 Core 读取。生产发布应由独立 CI 签名并校验清单、版本、健康路径和兼容的 Core 版本;不要把发布私钥放入仓库或插件包。插件版本独立于 `backend/cmd/server/VERSION`。 + +## 已知限制 + +- V1 使用内存会话,服务重启会要求重新登录;多实例部署需将会话存储替换为插件自有 Redis/共享会话服务。 +- 现有 Core 自定义 iframe 没有 token handoff,V1 不提供无感 SSO;真正 SSO 需要单独的 V1.1 Core 交接接口。 +- Core 当前套餐响应中的 `features` 可能是 JSON 字符串,UI 会兼容字符串和数组。 +- Core 开启验证码时,管理员必须先完成对应提供商的挑战并将结果填入登录表单;插件不保存验证码票据。 diff --git a/plugins/subscription-admin/build.sh b/plugins/subscription-admin/build.sh new file mode 100755 index 0000000..68b789d --- /dev/null +++ b/plugins/subscription-admin/build.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env sh +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +mkdir -p "$ROOT/bin" +CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o "$ROOT/bin/subscription-admin" "$ROOT" diff --git a/plugins/subscription-admin/business-plugin-manifest.v1.json b/plugins/subscription-admin/business-plugin-manifest.v1.json new file mode 100644 index 0000000..837ffc6 --- /dev/null +++ b/plugins/subscription-admin/business-plugin-manifest.v1.json @@ -0,0 +1,44 @@ +{ + "schema_version": 1, + "plugin_id": "qiu.subscription-admin", + "name": "Subscription Admin", + "version": "0.1.1", + "core_api_baseline": "sub2api-0.1.183", + "capabilities": ["subscription.admin.v1"], + "tested_core_versions": ["0.1.183"], + "backend": { + "health_path": "/healthz", + "readiness_path": "/readyz", + "listen_env": "PLUGIN_PORT" + }, + "ui": { + "entrypoint": "ui/index.html", + "menu": { + "id": "qiu.subscription-admin", + "label": "订阅管理", + "visibility": "admin", + "sort_order": 200 + } + }, + "publisher": { + "key_id": "qiu-subscription-admin-dev" + }, + "core_api_allowlist": [ + "POST /api/v1/auth/login", + "POST /api/v1/auth/login/2fa", + "POST /api/v1/auth/refresh", + "POST /api/v1/auth/logout", + "GET /api/v1/auth/me", + "GET /api/v1/settings/public", + "GET /api/v1/admin/payment/plans", + "GET /api/v1/admin/subscriptions", + "GET /api/v1/admin/subscriptions/{id}", + "GET /api/v1/admin/users/{id}", + "GET /api/v1/admin/users/{id}/subscriptions" + ], + "files": { + "ui/index.html": "a189f81675f1bf7820111123221d8056111c86ca104fad2906e961fad6ef4697", + "ui/app.js": "51896358caa769c1fc6353613b92d02bbdd340a24dca567b4f86fcaf1f5f36ca", + "ui/styles.css": "d96dff24fa6f7d96a977f5d8f09986cedb987aad1bb26177b9bf4d7b5982ae54" + } +} diff --git a/plugins/subscription-admin/deploy/Caddyfile.example b/plugins/subscription-admin/deploy/Caddyfile.example new file mode 100644 index 0000000..357bae8 --- /dev/null +++ b/plugins/subscription-admin/deploy/Caddyfile.example @@ -0,0 +1,12 @@ +# Keep the plugin service private on loopback; expose it behind HTTPS. +CORE_ORIGIN { + handle_path /extensions/qiu.subscription-admin/* { + reverse_proxy 127.0.0.1:8091 + } +} + +# Start the plugin with: +# PLUGIN_PUBLIC_BASE_PATH=/extensions/qiu.subscription-admin +# PLUGIN_COOKIE_PATH=/extensions/qiu.subscription-admin/ +# PLUGIN_COOKIE_SECURE=true +# PLUGIN_FRAME_ANCESTORS='self' diff --git a/plugins/subscription-admin/deploy/custom-menu-item.json b/plugins/subscription-admin/deploy/custom-menu-item.json new file mode 100644 index 0000000..7e947f5 --- /dev/null +++ b/plugins/subscription-admin/deploy/custom-menu-item.json @@ -0,0 +1,7 @@ +{ + "id": "qiu.subscription-admin", + "label": "订阅管理", + "url": "https://CORE_ORIGIN/extensions/qiu.subscription-admin/", + "visibility": "admin", + "sort_order": 200 +} diff --git a/plugins/subscription-admin/deploy/nginx.conf.example b/plugins/subscription-admin/deploy/nginx.conf.example new file mode 100644 index 0000000..a3e77c7 --- /dev/null +++ b/plugins/subscription-admin/deploy/nginx.conf.example @@ -0,0 +1,17 @@ +# Deploy beside the Core reverse proxy. Keep the plugin bound to loopback. +location /extensions/qiu.subscription-admin/ { + proxy_pass http://127.0.0.1:8091/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 30s; + proxy_send_timeout 30s; +} + +# Start the plugin with: +# PLUGIN_PUBLIC_BASE_PATH=/extensions/qiu.subscription-admin +# PLUGIN_COOKIE_PATH=/extensions/qiu.subscription-admin/ +# PLUGIN_COOKIE_SECURE=true +# PLUGIN_FRAME_ANCESTORS='self' diff --git a/plugins/subscription-admin/deploy/systemd.service.example b/plugins/subscription-admin/deploy/systemd.service.example new file mode 100644 index 0000000..84aa3cf --- /dev/null +++ b/plugins/subscription-admin/deploy/systemd.service.example @@ -0,0 +1,22 @@ +[Unit] +Description=Sub2API Subscription Admin business plugin +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=sub2api-plugin +Group=sub2api-plugin +WorkingDirectory=/opt/sub2api/subscription-admin +EnvironmentFile=/etc/sub2api/subscription-admin.env +ExecStart=/opt/sub2api/subscription-admin/bin/subscription-admin +Restart=on-failure +RestartSec=3 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/sub2api/subscription-admin + +[Install] +WantedBy=multi-user.target diff --git a/plugins/subscription-admin/go.mod b/plugins/subscription-admin/go.mod new file mode 100644 index 0000000..d7779aa --- /dev/null +++ b/plugins/subscription-admin/go.mod @@ -0,0 +1,3 @@ +module git.awaioi.com/awaioi/sub2api-add/plugins/subscription-admin + +go 1.23 diff --git a/plugins/subscription-admin/internal/manifest/manifest.go b/plugins/subscription-admin/internal/manifest/manifest.go new file mode 100644 index 0000000..737d1db --- /dev/null +++ b/plugins/subscription-admin/internal/manifest/manifest.go @@ -0,0 +1,204 @@ +package manifest + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "regexp" + "sort" + "strings" +) + +var pluginIDPattern = regexp.MustCompile(`^[a-z0-9]+([._-][a-z0-9]+)+$`) +var versionPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$`) + +var requiredAllowlist = []string{ + "POST /api/v1/auth/login", + "POST /api/v1/auth/login/2fa", + "POST /api/v1/auth/refresh", + "POST /api/v1/auth/logout", + "GET /api/v1/auth/me", + "GET /api/v1/settings/public", + "GET /api/v1/admin/payment/plans", + "GET /api/v1/admin/subscriptions", + "GET /api/v1/admin/subscriptions/{id}", + "GET /api/v1/admin/users/{id}", + "GET /api/v1/admin/users/{id}/subscriptions", +} + +type Manifest struct { + SchemaVersion int `json:"schema_version"` + PluginID string `json:"plugin_id"` + Name string `json:"name"` + Version string `json:"version"` + CoreAPIBaseline string `json:"core_api_baseline"` + Capabilities []string `json:"capabilities"` + TestedCoreVersions []string `json:"tested_core_versions"` + Backend struct { + HealthPath string `json:"health_path"` + ReadinessPath string `json:"readiness_path"` + ListenEnv string `json:"listen_env"` + } `json:"backend"` + UI struct { + Entrypoint string `json:"entrypoint"` + Menu struct { + ID string `json:"id"` + Label string `json:"label"` + Visibility string `json:"visibility"` + SortOrder int `json:"sort_order"` + } `json:"menu"` + } `json:"ui"` + Publisher struct { + KeyID string `json:"key_id"` + } `json:"publisher"` + CoreAPIAllowlist []string `json:"core_api_allowlist"` + Files map[string]string `json:"files,omitempty"` +} + +type Signature struct { + Algorithm string `json:"algorithm"` + KeyID string `json:"key_id"` + Signature string `json:"signature"` +} + +func Load(path string) (Manifest, []byte, error) { + raw, err := os.ReadFile(path) + if err != nil { + return Manifest{}, nil, err + } + var m Manifest + dec := json.NewDecoder(strings.NewReader(string(raw))) + dec.DisallowUnknownFields() + if err := dec.Decode(&m); err != nil { + return Manifest{}, nil, fmt.Errorf("decode manifest: %w", err) + } + var trailing any + if err := dec.Decode(&trailing); err != io.EOF { + if err == nil { + return Manifest{}, nil, errors.New("manifest contains trailing JSON") + } + return Manifest{}, nil, fmt.Errorf("decode manifest trailing data: %w", err) + } + if err := Validate(m); err != nil { + return Manifest{}, nil, err + } + return m, raw, nil +} + +func Validate(m Manifest) error { + if m.SchemaVersion != 1 { + return fmt.Errorf("schema_version must be 1") + } + if !pluginIDPattern.MatchString(m.PluginID) { + return fmt.Errorf("invalid plugin_id") + } + if strings.TrimSpace(m.Name) == "" || len(m.Name) > 160 { + return fmt.Errorf("name is required and must be at most 160 characters") + } + if !versionPattern.MatchString(strings.TrimPrefix(m.Version, "v")) { + return fmt.Errorf("invalid plugin version") + } + baseline := strings.TrimPrefix(m.CoreAPIBaseline, "sub2api-") + if !versionPattern.MatchString(strings.TrimPrefix(baseline, "v")) { + return fmt.Errorf("invalid core_api_baseline") + } + if len(m.Capabilities) != 1 || m.Capabilities[0] != "subscription.admin.v1" { + return fmt.Errorf("capabilities must contain subscription.admin.v1 only") + } + for _, version := range m.TestedCoreVersions { + if !versionPattern.MatchString(strings.TrimPrefix(version, "v")) { + return fmt.Errorf("invalid tested_core_versions entry") + } + } + if m.Backend.HealthPath != "/healthz" || m.Backend.ReadinessPath != "/readyz" || m.Backend.ListenEnv != "PLUGIN_PORT" { + return fmt.Errorf("backend health_path/listen_env do not match V1 contract") + } + if (m.UI.Entrypoint != "/admin" && m.UI.Entrypoint != "/admin/" && m.UI.Entrypoint != "ui/index.html") || m.UI.Menu.ID != m.PluginID || strings.TrimSpace(m.UI.Menu.Label) == "" || m.UI.Menu.Visibility != "admin" || m.UI.Menu.SortOrder < 0 || m.Publisher.KeyID == "" { + return fmt.Errorf("ui.entrypoint/menu and publisher.key_id are required") + } + if len(m.CoreAPIAllowlist) != len(requiredAllowlist) { + return fmt.Errorf("core_api_allowlist must contain exactly %d entries", len(requiredAllowlist)) + } + seen := make(map[string]struct{}, len(m.CoreAPIAllowlist)) + for _, entry := range m.CoreAPIAllowlist { + if _, ok := seen[entry]; ok { + return fmt.Errorf("duplicate allowlist entry: %s", entry) + } + seen[entry] = struct{}{} + } + for _, required := range requiredAllowlist { + if _, ok := seen[required]; !ok { + return fmt.Errorf("missing allowlist entry: %s", required) + } + } + for file, hash := range m.Files { + if strings.TrimSpace(file) == "" || strings.HasPrefix(strings.ReplaceAll(file, "\\", "/"), "/") || strings.Contains(strings.ReplaceAll(file, "\\", "/"), "..") { + return fmt.Errorf("invalid file declaration: %s", file) + } + if _, err := hex.DecodeString(hash); err != nil || len(hash) != 64 { + return fmt.Errorf("invalid file hash declaration: %s", file) + } + } + return nil +} + +// DeclaredFiles returns file paths in deterministic order for package tooling. +func DeclaredFiles(m Manifest) []string { + files := make([]string, 0, len(m.Files)) + for file := range m.Files { + files = append(files, file) + } + sort.Strings(files) + return files +} + +func VerifySignature(manifestBytes, signatureBytes, publicKeyBytes []byte) error { + var signature Signature + dec := json.NewDecoder(strings.NewReader(string(signatureBytes))) + dec.DisallowUnknownFields() + if err := dec.Decode(&signature); err != nil { + return fmt.Errorf("decode signature: %w", err) + } + var trailing any + if err := dec.Decode(&trailing); err != io.EOF { + if err == nil { + return errors.New("signature contains trailing JSON") + } + return fmt.Errorf("decode signature trailing data: %w", err) + } + if signature.Algorithm != "ed25519" || signature.KeyID == "" { + return errors.New("signature must use ed25519 and include key_id") + } + publicKey, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(publicKeyBytes))) + if err != nil || len(publicKey) != ed25519.PublicKeySize { + return errors.New("invalid base64 ed25519 public key") + } + sig, err := base64.StdEncoding.DecodeString(signature.Signature) + if err != nil || len(sig) != ed25519.SignatureSize { + return errors.New("invalid base64 ed25519 signature") + } + if !ed25519.Verify(ed25519.PublicKey(publicKey), manifestBytes, sig) { + return errors.New("manifest signature verification failed") + } + return nil +} + +func VerifyKeyID(signatureBytes []byte, expectedKeyID string) error { + var signature Signature + if err := json.Unmarshal(signatureBytes, &signature); err != nil { + return fmt.Errorf("decode signature: %w", err) + } + if strings.TrimSpace(expectedKeyID) == "" || signature.KeyID != expectedKeyID { + return errors.New("signature key_id does not match manifest publisher") + } + return nil +} + +func RequiredAllowlist() []string { + return append([]string(nil), requiredAllowlist...) +} diff --git a/plugins/subscription-admin/internal/manifest/manifest_test.go b/plugins/subscription-admin/internal/manifest/manifest_test.go new file mode 100644 index 0000000..76b4809 --- /dev/null +++ b/plugins/subscription-admin/internal/manifest/manifest_test.go @@ -0,0 +1,62 @@ +package manifest + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "testing" +) + +func validManifest() Manifest { + var m Manifest + m.SchemaVersion = 1 + m.PluginID = "qiu.subscription-admin" + m.Name = "Subscription Admin" + m.Version = "0.1.0" + m.CoreAPIBaseline = "sub2api-0.1.183" + m.Capabilities = []string{"subscription.admin.v1"} + m.TestedCoreVersions = []string{"0.1.183"} + m.Backend.HealthPath = "/healthz" + m.Backend.ReadinessPath = "/readyz" + m.Backend.ListenEnv = "PLUGIN_PORT" + m.UI.Entrypoint = "ui/index.html" + m.UI.Menu.ID = m.PluginID + m.UI.Menu.Label = "订阅管理" + m.UI.Menu.Visibility = "admin" + m.UI.Menu.SortOrder = 200 + m.Publisher.KeyID = "test-key" + m.CoreAPIAllowlist = RequiredAllowlist() + return m +} + +func TestValidateManifest(t *testing.T) { + if err := Validate(validManifest()); err != nil { + t.Fatal(err) + } + m := validManifest() + m.CoreAPIAllowlist = append(m.CoreAPIAllowlist, "GET /api/v1/admin/users") + if err := Validate(m); err == nil { + t.Fatal("expected exact allowlist validation failure") + } +} + +func TestVerifySignature(t *testing.T) { + publicKey, privateKey, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + manifestBytes := []byte(`{"schema_version":1}`) + signature := Signature{Algorithm: "ed25519", KeyID: "test-key", Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, manifestBytes))} + signatureBytes, err := json.Marshal(signature) + if err != nil { + t.Fatal(err) + } + publicKeyBytes := []byte(base64.StdEncoding.EncodeToString(publicKey)) + if err := VerifySignature(manifestBytes, signatureBytes, publicKeyBytes); err != nil { + t.Fatal(err) + } + manifestBytes[0] = '{' + if err := VerifySignature([]byte(`{"schema_version":2}`), signatureBytes, publicKeyBytes); err == nil { + t.Fatal("expected tamper failure") + } +} diff --git a/plugins/subscription-admin/main.go b/plugins/subscription-admin/main.go new file mode 100644 index 0000000..1d9df90 --- /dev/null +++ b/plugins/subscription-admin/main.go @@ -0,0 +1,1218 @@ +// Command subscription-admin is the standalone read-only subscription business plugin. +// It deliberately uses net/http and the Core public HTTP contract; it does not import +// Core internal packages or the .s2plugin transport ABI. +package main + +import ( + "context" + "crypto/rand" + "embed" + "encoding/hex" + "encoding/json" + "errors" + "html" + "io" + "log/slog" + "net" + "net/http" + "net/url" + "os" + "path" + "regexp" + "strconv" + "strings" + "sync" + "time" +) + +const ( + sessionCookieName = "subscription_admin_session" + maxBodyBytes = 1 << 20 + coreRequestLimit = 4 << 20 + sessionTTL = 30 * time.Minute + sessionMaxTTL = 8 * time.Hour + pendingTTL = 5 * time.Minute + pluginID = "qiu.subscription-admin" + pluginVersion = "0.1.1" + requestIDHeader = "X-Request-ID" + maxRequestIDBytes = 64 +) + +var requestIDPattern = regexp.MustCompile(`^[A-Za-z0-9._:-]+$`) + +//go:embed ui/* +var uiFS embed.FS + +type coreClient struct { + base string + http *http.Client +} + +func newCoreClient(base string) (*coreClient, error) { + base = strings.TrimRight(strings.TrimSpace(base), "/") + u, err := url.Parse(base) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") { + return nil, errors.New("CORE_BASE_URL must be an absolute origin URL without credentials, path, query, or fragment") + } + if u.Scheme == "http" && !isLoopbackHost(u.Hostname()) { + return nil, errors.New("CORE_BASE_URL must use HTTPS unless Core is on loopback") + } + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = nil + return &coreClient{ + base: base, + http: &http.Client{ + Timeout: 10 * time.Second, + Transport: transport, + // Core API calls must never follow a redirect to an untrusted host. + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }, + }, + }, nil +} + +func isLoopbackHost(host string) bool { + if strings.EqualFold(strings.TrimSuffix(host, "."), "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +type coreEnvelope struct { + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` + status int +} + +type coreError struct { + status int +} + +func (e *coreError) Error() string { + return "core request failed" +} + +// call is the only Core HTTP boundary. Paths are selected by typed methods below, +// never from a browser-supplied URL. +func (c *coreClient) call(ctx context.Context, method, requestPath string, body any, accessToken, correlationID, clientIP string) (coreEnvelope, error) { + var reader io.Reader + if body != nil { + payload, err := json.Marshal(body) + if err != nil { + return coreEnvelope{}, err + } + reader = strings.NewReader(string(payload)) + } + req, err := http.NewRequestWithContext(ctx, method, c.base+requestPath, reader) + if err != nil { + return coreEnvelope{}, err + } + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if accessToken != "" { + req.Header.Set("Authorization", "Bearer "+accessToken) + } + req.Header.Set(requestIDHeader, normalizeRequestID(correlationID)) + if clientIP != "" { + req.Header.Set("X-Forwarded-For", clientIP) + req.Header.Set("X-Real-IP", clientIP) + } + res, err := c.http.Do(req) + if err != nil { + return coreEnvelope{}, err + } + defer res.Body.Close() + var envelope coreEnvelope + if err := json.NewDecoder(io.LimitReader(res.Body, coreRequestLimit)).Decode(&envelope); err != nil { + return coreEnvelope{}, err + } + envelope.status = res.StatusCode + if res.StatusCode >= http.StatusMultipleChoices { + return envelope, &coreError{status: res.StatusCode} + } + if envelope.Code != 0 { + status := envelope.Code + if status < http.StatusBadRequest || status >= 600 { + status = http.StatusBadGateway + } + return envelope, &coreError{status: status} + } + return envelope, nil +} + +type loginRequest struct { + Email string `json:"email"` + Password string `json:"password"` + TurnstileToken string `json:"turnstile_token,omitempty"` + TencentCaptchaTicket string `json:"tencent_captcha_ticket,omitempty"` + TencentCaptchaRandstr string `json:"tencent_captcha_randstr,omitempty"` +} + +func (c *coreClient) login(ctx context.Context, in loginRequest, correlationID, clientIP string) (coreEnvelope, error) { + return c.call(ctx, http.MethodPost, "/api/v1/auth/login", in, "", correlationID, clientIP) +} + +func (c *coreClient) login2FA(ctx context.Context, tempToken, code, correlationID, clientIP string) (coreEnvelope, error) { + return c.call(ctx, http.MethodPost, "/api/v1/auth/login/2fa", map[string]string{"temp_token": tempToken, "totp_code": code}, "", correlationID, clientIP) +} + +func (c *coreClient) refresh(ctx context.Context, refreshToken, correlationID string) (coreEnvelope, error) { + return c.call(ctx, http.MethodPost, "/api/v1/auth/refresh", map[string]string{"refresh_token": refreshToken}, "", correlationID, "") +} + +func (c *coreClient) logout(ctx context.Context, refreshToken, correlationID string) { + if c == nil || refreshToken == "" { + return + } + _, _ = c.call(ctx, http.MethodPost, "/api/v1/auth/logout", map[string]string{"refresh_token": refreshToken}, "", correlationID, "") +} + +func (c *coreClient) me(ctx context.Context, accessToken, correlationID string) (coreEnvelope, error) { + return c.call(ctx, http.MethodGet, "/api/v1/auth/me", nil, accessToken, correlationID, "") +} + +func (c *coreClient) publicSettings(ctx context.Context, correlationID string) (coreEnvelope, error) { + return c.call(ctx, http.MethodGet, "/api/v1/settings/public", nil, "", correlationID, "") +} + +func (c *coreClient) read(ctx context.Context, requestPath, accessToken string, correlationIDs ...string) (coreEnvelope, error) { + correlationID := "" + if len(correlationIDs) > 0 { + correlationID = correlationIDs[0] + } + u, err := url.Parse(requestPath) + if err != nil || u.Host != "" || u.Scheme != "" || !allowedReadPath(u.Path) { + return coreEnvelope{}, errors.New("core path is not in the allowlist") + } + return c.call(ctx, http.MethodGet, u.EscapedPath()+queryString(sanitizeQuery(u.Query())), nil, accessToken, correlationID, "") +} + +func queryString(values url.Values) string { + if encoded := values.Encode(); encoded != "" { + return "?" + encoded + } + return "" +} + +func allowedReadPath(path string) bool { + parsed, err := url.Parse(path) + if err != nil || parsed.Host != "" || parsed.Scheme != "" || parsed.Fragment != "" { + return false + } + path = parsed.Path + if path == "/api/v1/admin/payment/plans" || path == "/api/v1/admin/subscriptions" { + return true + } + if strings.HasPrefix(path, "/api/v1/admin/subscriptions/") { + return positiveID(strings.TrimPrefix(path, "/api/v1/admin/subscriptions/")) + } + if strings.HasPrefix(path, "/api/v1/admin/users/") { + rest := strings.TrimPrefix(path, "/api/v1/admin/users/") + if positiveID(rest) { + return true + } + parts := strings.Split(rest, "/") + return len(parts) == 2 && positiveID(parts[0]) && parts[1] == "subscriptions" + } + return false +} + +func allowedQuery(r *http.Request) string { + if r == nil { + return "" + } + return queryString(sanitizeQuery(r.URL.Query())) +} + +func validQuery(query url.Values) bool { + for _, key := range []string{"page", "page_size", "limit", "user_id", "group_id"} { + for _, raw := range query[key] { + value := strings.TrimSpace(raw) + if value == "" { + continue + } + n, err := strconv.ParseUint(value, 10, 63) + if err != nil || n == 0 || ((key == "page_size" || key == "limit") && n > 100) { + return false + } + } + } + return true +} + +func sanitizeQuery(query url.Values) url.Values { + allowed := []string{"page", "page_size", "limit", "user_id", "group_id", "status", "platform", "sort_by", "sort_order"} + values := url.Values{} + for _, key := range allowed { + for _, value := range query[key] { + value = strings.TrimSpace(value) + if value == "" || len(value) > 100 { + continue + } + if key == "page" || key == "page_size" || key == "limit" || key == "user_id" || key == "group_id" { + if _, err := strconv.ParseUint(value, 10, 63); err != nil { + continue + } + } + values.Add(key, value) + } + } + return values +} + +func positiveID(value string) bool { + n, err := strconv.ParseInt(value, 10, 64) + return err == nil && n > 0 +} + +type session struct { + accessToken string + refreshToken string + csrfToken string + user map[string]any + createdAt time.Time + lastSeen time.Time +} + +type pendingLogin struct { + tempToken string + expires time.Time + clientIP string +} + +type auditEvent struct { + Time time.Time `json:"time"` + PluginID string `json:"plugin_id"` + Action string `json:"action"` + Result string `json:"result"` + UserID any `json:"user_id,omitempty"` + ResourceID string `json:"resource_id,omitempty"` + Request string `json:"request_id"` +} + +type app struct { + core *coreClient + sessions map[string]session + sessionLocks map[string]*sync.Mutex + pending map[string]pendingLogin + audit []auditEvent + mu sync.Mutex + cookieSecure bool + cookieSameSite http.SameSite + cookiePath string + publicBasePath string + frameAncestors []string + trustProxy bool + loginWindow time.Duration + loginLimit int + loginAttempts map[string]loginAttempt + clock func() time.Time +} + +type loginAttempt struct { + started time.Time + count int +} + +func newApp(core *coreClient, secure bool) *app { + return newAppWithConfig(core, appConfig{CookieSecure: secure, CookieSameSite: http.SameSiteLaxMode, CookiePath: "/", PublicBasePath: "", FrameAncestors: []string{"'self'"}}) +} + +type appConfig struct { + CookieSecure bool + CookieSameSite http.SameSite + CookiePath string + PublicBasePath string + FrameAncestors []string + TrustProxy bool + LoginWindow time.Duration + LoginLimit int +} + +func newAppWithConfig(core *coreClient, cfg appConfig) *app { + if cfg.CookieSameSite == 0 { + cfg.CookieSameSite = http.SameSiteLaxMode + } + cfg.CookiePath = normalizeCookiePath(cfg.CookiePath) + cfg.PublicBasePath = normalizeBasePath(cfg.PublicBasePath) + if len(cfg.FrameAncestors) == 0 { + cfg.FrameAncestors = []string{"'self'"} + } + if cfg.LoginWindow <= 0 { + cfg.LoginWindow = time.Minute + } + if cfg.LoginLimit <= 0 { + cfg.LoginLimit = 10 + } + return &app{ + core: core, + sessions: make(map[string]session), + sessionLocks: make(map[string]*sync.Mutex), + pending: make(map[string]pendingLogin), + audit: make([]auditEvent, 0, 200), + cookieSecure: cfg.CookieSecure, + cookieSameSite: cfg.CookieSameSite, + cookiePath: cfg.CookiePath, + publicBasePath: cfg.PublicBasePath, + frameAncestors: append([]string(nil), cfg.FrameAncestors...), + trustProxy: cfg.TrustProxy, + loginWindow: cfg.LoginWindow, + loginLimit: cfg.LoginLimit, + loginAttempts: make(map[string]loginAttempt), + clock: time.Now, + } +} + +func normalizeCookiePath(value string) string { + value = strings.TrimSpace(value) + if value == "" || value == "/" { + return "/" + } + clean := path.Clean("/" + strings.Trim(value, "/")) + return clean + "/" +} + +func normalizeBasePath(value string) string { + value = strings.TrimSpace(value) + if value == "" || value == "/" { + return "" + } + clean := path.Clean("/" + strings.Trim(value, "/")) + return clean +} + +func (a *app) basePath() string { + return a.publicBasePath +} + +func (a *app) addAudit(ctx context.Context, action, result string, userID any, resourceID string) { + a.mu.Lock() + defer a.mu.Unlock() + a.audit = append(a.audit, auditEvent{Time: a.clock(), PluginID: pluginID, Action: action, Result: result, UserID: userID, ResourceID: resourceID, Request: requestIDFromContext(ctx)}) + if len(a.audit) > 200 { + a.audit = a.audit[len(a.audit)-200:] + } +} + +func requestID() string { return token(16) } + +type requestIDContextKey struct{} + +func normalizeRequestID(value string) string { + value = strings.TrimSpace(value) + if value == "" || len(value) > maxRequestIDBytes || !requestIDPattern.MatchString(value) { + return requestID() + } + return value +} + +func requestIDFromContext(ctx context.Context) string { + if ctx != nil { + if value, ok := ctx.Value(requestIDContextKey{}).(string); ok && value != "" { + return value + } + } + return requestID() +} + +func requestIDMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + correlationID := normalizeRequestID(r.Header.Get(requestIDHeader)) + w.Header().Set(requestIDHeader, correlationID) + ctx := context.WithValue(r.Context(), requestIDContextKey{}, correlationID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func token(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return hex.EncodeToString(b) +} + +func decodeJSON(r *http.Request, out any) error { + defer r.Body.Close() + dec := json.NewDecoder(io.LimitReader(r.Body, maxBodyBytes)) + dec.DisallowUnknownFields() + return dec.Decode(out) +} + +func (a *app) writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func (a *app) sessionFromRequest(r *http.Request) (string, session, bool) { + cookie, err := r.Cookie(sessionCookieName) + if err != nil || cookie.Value == "" { + return "", session{}, false + } + a.mu.Lock() + defer a.mu.Unlock() + s, ok := a.sessions[cookie.Value] + if !ok { + return "", session{}, false + } + now := a.clock() + if now.Sub(s.createdAt) > sessionMaxTTL || now.Sub(s.lastSeen) > sessionTTL { + delete(a.sessions, cookie.Value) + delete(a.sessionLocks, cookie.Value) + return "", session{}, false + } + s.lastSeen = now + a.sessions[cookie.Value] = s + return cookie.Value, s, true +} + +func (a *app) setSessionCookie(w http.ResponseWriter, id string, maxAge int) { + http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: id, Path: a.cookiePath, HttpOnly: true, Secure: a.cookieSecure, SameSite: a.cookieSameSite, MaxAge: maxAge}) +} + +func (a *app) allowLoginAttempt(r *http.Request, identity string) bool { + key := trustedClientIPWithConfig(r, a.trustProxy) + "|" + strings.ToLower(strings.TrimSpace(identity)) + now := a.clock() + a.mu.Lock() + defer a.mu.Unlock() + attempt := a.loginAttempts[key] + if attempt.started.IsZero() || now.Sub(attempt.started) >= a.loginWindow { + attempt = loginAttempt{started: now} + } + if attempt.count >= a.loginLimit { + a.loginAttempts[key] = attempt + return false + } + attempt.count++ + a.loginAttempts[key] = attempt + return true +} + +func (a *app) clientIP(r *http.Request) string { + return trustedClientIPWithConfig(r, a.trustProxy) +} + +func (a *app) login(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + a.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + var in loginRequest + if err := decodeJSON(r, &in); err != nil || strings.TrimSpace(in.Email) == "" || in.Password == "" { + a.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid credentials"}) + return + } + in.Email = strings.TrimSpace(in.Email) + if !a.allowLoginAttempt(r, in.Email) { + w.Header().Set("Retry-After", "60") + a.writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "too many login attempts"}) + return + } + out, err := a.core.login(r.Context(), in, requestIDFromContext(r.Context()), a.clientIP(r)) + if err != nil { + a.addAudit(r.Context(), "login", "core_unavailable", nil, "") + a.coreError(w, err, "core login failed") + return + } + data := envelopeData(out) + if requires, _ := data["requires_2fa"].(bool); requires { + temp, _ := data["temp_token"].(string) + if temp == "" { + a.writeJSON(w, http.StatusBadGateway, map[string]string{"error": "core 2fa challenge missing"}) + return + } + pendingID := token(24) + a.mu.Lock() + a.pending[pendingID] = pendingLogin{tempToken: temp, expires: a.clock().Add(pendingTTL), clientIP: a.clientIP(r)} + a.mu.Unlock() + a.writeJSON(w, http.StatusOK, map[string]any{"requires_2fa": true, "pending_token": pendingID}) + return + } + a.finishLogin(w, r, data) +} + +func (a *app) login2FA(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + a.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + var in struct { + PendingToken string `json:"pending_token"` + TotpCode string `json:"totp_code"` + } + if err := decodeJSON(r, &in); err != nil || in.PendingToken == "" || len(in.TotpCode) != 6 { + a.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid 2fa request"}) + return + } + if !a.allowLoginAttempt(r, in.PendingToken) { + w.Header().Set("Retry-After", "60") + a.writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "too many login attempts"}) + return + } + a.mu.Lock() + pending, ok := a.pending[in.PendingToken] + delete(a.pending, in.PendingToken) + a.mu.Unlock() + if !ok || a.clock().After(pending.expires) || (pending.clientIP != "" && pending.clientIP != a.clientIP(r)) { + a.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "2fa session expired"}) + return + } + out, err := a.core.login2FA(r.Context(), pending.tempToken, in.TotpCode, requestIDFromContext(r.Context()), a.clientIP(r)) + if err != nil { + a.coreError(w, err, "2fa verification failed") + return + } + a.finishLogin(w, r, envelopeData(out)) +} + +func (a *app) finishLogin(w http.ResponseWriter, r *http.Request, data map[string]any) { + access, _ := data["access_token"].(string) + refresh, _ := data["refresh_token"].(string) + if access == "" { + a.writeJSON(w, http.StatusBadGateway, map[string]string{"error": "core token missing"}) + return + } + correlationID := requestIDFromContext(r.Context()) + me, err := a.core.me(r.Context(), access, correlationID) + if err != nil { + a.core.logout(r.Context(), refresh, correlationID) + a.addAudit(r.Context(), "login", "admin_verification_failed", nil, "") + a.writeJSON(w, http.StatusForbidden, map[string]string{"error": "admin verification failed"}) + return + } + user := envelopeData(me) + if !isAdmin(user) { + a.core.logout(r.Context(), refresh, correlationID) + a.addAudit(r.Context(), "login", "forbidden", user["id"], "") + a.writeJSON(w, http.StatusForbidden, map[string]string{"error": "admin role required"}) + return + } + now := a.clock() + s := session{accessToken: access, refreshToken: refresh, csrfToken: token(24), user: publicUser(user), createdAt: now, lastSeen: now} + id := token(32) + a.mu.Lock() + a.sessions[id] = s + a.sessionLocks[id] = &sync.Mutex{} + a.mu.Unlock() + a.addAudit(r.Context(), "login", "success", user["id"], "") + a.setSessionCookie(w, id, int(sessionMaxTTL/time.Second)) + // The response intentionally contains only the plugin CSRF token and sanitized user. + a.writeJSON(w, http.StatusOK, map[string]any{"ok": true, "csrf_token": s.csrfToken, "user": s.user}) +} + +func (a *app) logout(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + a.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + if id, s, ok := a.sessionFromRequest(r); ok { + if r.Header.Get("X-CSRF-Token") != s.csrfToken { + a.addAudit(r.Context(), "logout", "csrf_failed", s.user["id"], "") + a.writeJSON(w, http.StatusForbidden, map[string]string{"error": "csrf validation failed"}) + return + } + a.removeSessionByID(id) + a.core.logout(r.Context(), s.refreshToken, requestIDFromContext(r.Context())) + a.addAudit(r.Context(), "logout", "success", s.user["id"], "") + } + a.setSessionCookie(w, "", -1) + a.writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +func (a *app) authenticate(w http.ResponseWriter, r *http.Request) (string, session, bool, bool) { + id, s, ok := a.sessionFromRequest(r) + if !ok { + a.setSessionCookie(w, "", -1) + a.writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"}) + return "", session{}, false, false + } + if a.core == nil { + a.writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "core unavailable"}) + return "", session{}, false, false + } + if r.Method != http.MethodGet && r.Header.Get("X-CSRF-Token") != s.csrfToken { + a.writeJSON(w, http.StatusForbidden, map[string]string{"error": "csrf validation failed"}) + return "", session{}, false, false + } + correlationID := requestIDFromContext(r.Context()) + me, err := a.core.me(r.Context(), s.accessToken, correlationID) + if err != nil { + if ce, ok := err.(*coreError); ok && ce.status == http.StatusUnauthorized && s.refreshToken != "" { + if refreshedSession, refreshOK := a.refreshSession(r.Context(), id, s); refreshOK { + return id, refreshedSession, true, true + } + } + a.removeSessionIfCurrent(id, s.accessToken) + a.core.logout(r.Context(), s.refreshToken, correlationID) + a.setSessionCookie(w, "", -1) + if ce, ok := err.(*coreError); ok && ce.status == http.StatusUnauthorized { + a.writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "core session expired"}) + } else { + a.coreError(w, err, "core session unavailable") + } + return "", session{}, false, false + } + if !isAdmin(envelopeData(me)) { + a.removeSessionByID(id) + a.core.logout(r.Context(), s.refreshToken, correlationID) + a.setSessionCookie(w, "", -1) + a.writeJSON(w, http.StatusForbidden, map[string]string{"error": "admin role required"}) + return "", session{}, false, false + } + return id, s, false, true +} + +func (a *app) me(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + a.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + _, s, _, ok := a.authenticate(w, r) + if ok { + a.writeJSON(w, http.StatusOK, map[string]any{"user": s.user, "csrf_token": s.csrfToken, "plugin_id": pluginID, "plugin_version": pluginVersion}) + } +} + +func (a *app) status(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + a.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + _, s, _, ok := a.authenticate(w, r) + if !ok { + return + } + a.writeJSON(w, http.StatusOK, map[string]any{ + "plugin_id": pluginID, + "plugin_version": pluginVersion, + "mode": "read_only", + "core_base_configured": a.core != nil, + "session_mode": "memory", + "credential_state": "server_managed", + "operator": s.user, + }) +} + +func (a *app) captchaConfig(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + a.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + if a.core == nil { + a.writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "core unavailable"}) + return + } + out, err := a.core.publicSettings(r.Context(), requestIDFromContext(r.Context())) + if err != nil { + a.coreError(w, err, "core settings unavailable") + return + } + data := envelopeData(out) + provider := "" + if enabled, _ := data["geetest_captcha_enabled"].(bool); enabled { + provider = "geetest" + } else if enabled, _ := data["turnstile_enabled"].(bool); enabled { + provider = "turnstile" + } else if enabled, _ := data["tencent_captcha_enabled"].(bool); enabled { + provider = "tencent" + } else if enabled, _ := data["aliyun_captcha_enabled"].(bool); enabled { + provider = "aliyun" + } + a.writeJSON(w, http.StatusOK, map[string]any{ + "enabled": provider != "", + "provider": provider, + "geetest_captcha_id": data["geetest_captcha_id"], + "turnstile_site_key": data["turnstile_site_key"], + "tencent_captcha_app_id": data["tencent_captcha_app_id"], + "tencent_captcha_region": data["tencent_captcha_region"], + "aliyun_captcha_scene_id": data["aliyun_captcha_scene_id"], + "aliyun_captcha_prefix": data["aliyun_captcha_prefix"], + }) +} + +func (a *app) userProxy(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + a.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + id := strings.TrimPrefix(r.URL.Path, "/api/users/") + if !positiveID(id) || strings.Contains(id, "/") { + http.NotFound(w, r) + return + } + sessionID, s, refreshed, ok := a.authenticate(w, r) + if !ok { + return + } + correlationID := requestIDFromContext(r.Context()) + corePath := "/api/v1/admin/users/" + id + out, err := a.core.read(r.Context(), corePath, s.accessToken, correlationID) + if err != nil { + if ce, unauthorized := err.(*coreError); unauthorized && ce.status == http.StatusUnauthorized && !refreshed && s.refreshToken != "" { + if next, refreshOK := a.refreshSession(r.Context(), sessionID, s); refreshOK { + s = next + out, err = a.core.read(r.Context(), corePath, next.accessToken, correlationID) + } + } + if err != nil { + a.coreError(w, err, "user lookup failed") + return + } + } + a.addAudit(r.Context(), "read:/api/v1/admin/users/{id}", "success", s.user["id"], id) + a.writeCoreEnvelope(w, out) +} + +func (a *app) readProxy(path string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + a.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + if !validQuery(r.URL.Query()) { + a.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid query parameters"}) + return + } + id, s, refreshed, ok := a.authenticate(w, r) + if !ok { + return + } + corePath := path + allowedQuery(r) + correlationID := requestIDFromContext(r.Context()) + out, err := a.core.read(r.Context(), corePath, s.accessToken, correlationID) + if err != nil { + if ce, unauthorized := err.(*coreError); unauthorized && ce.status == http.StatusUnauthorized && !refreshed && s.refreshToken != "" { + if refreshedSession, refreshOK := a.refreshSession(r.Context(), id, s); refreshOK { + s = refreshedSession + out, err = a.core.read(r.Context(), corePath, s.accessToken, correlationID) + } + } + if err != nil { + if ce, ok := err.(*coreError); ok && ce.status == http.StatusUnauthorized { + a.removeSessionIfCurrent(id, s.accessToken) + a.setSessionCookie(w, "", -1) + a.writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "core session expired"}) + return + } + a.coreError(w, err, "core request failed") + return + } + } + a.addAudit(r.Context(), "read:"+path, "success", s.user["id"], resourceIDFromPath(path)) + a.writeCoreEnvelope(w, out) + } +} + +func (a *app) updateSession(r *http.Request, updated session) { + cookie, err := r.Cookie(sessionCookieName) + if err != nil { + return + } + a.mu.Lock() + if current, ok := a.sessions[cookie.Value]; ok && current.accessToken != updated.accessToken { + // A concurrent request may have refreshed the session already. Preserve + // the newer token pair while still extending its activity timestamp. + current.lastSeen = updated.lastSeen + a.sessions[cookie.Value] = current + } else { + a.sessions[cookie.Value] = updated + } + a.mu.Unlock() +} + +func (a *app) refreshSession(ctx context.Context, id string, stale session) (session, bool) { + a.mu.Lock() + lock := a.sessionLocks[id] + a.mu.Unlock() + if lock == nil { + return session{}, false + } + lock.Lock() + defer lock.Unlock() + + a.mu.Lock() + current, ok := a.sessions[id] + a.mu.Unlock() + if !ok { + return session{}, false + } + if current.accessToken != stale.accessToken { + return current, true + } + if current.refreshToken == "" { + return session{}, false + } + correlationID := requestIDFromContext(ctx) + refreshed, err := a.core.refresh(ctx, current.refreshToken, correlationID) + if err != nil { + return session{}, false + } + data := envelopeData(refreshed) + access, _ := data["access_token"].(string) + if access == "" { + return session{}, false + } + nextMe, err := a.core.me(ctx, access, correlationID) + if err != nil || !isAdmin(envelopeData(nextMe)) { + return session{}, false + } + current.accessToken = access + if nextRefresh, ok := data["refresh_token"].(string); ok && nextRefresh != "" { + current.refreshToken = nextRefresh + } + current.user = publicUser(envelopeData(nextMe)) + current.lastSeen = a.clock() + a.mu.Lock() + latest, exists := a.sessions[id] + if exists && latest.accessToken == stale.accessToken { + a.sessions[id] = current + } else if exists { + current = latest + } + a.mu.Unlock() + return current, exists +} + +func (a *app) removeSessionByID(id string) { + a.mu.Lock() + delete(a.sessions, id) + delete(a.sessionLocks, id) + a.mu.Unlock() +} + +func (a *app) removeSessionIfCurrent(id, accessToken string) { + a.mu.Lock() + if current, ok := a.sessions[id]; ok && current.accessToken == accessToken { + delete(a.sessions, id) + delete(a.sessionLocks, id) + } + a.mu.Unlock() +} + +func trustedClientIPWithConfig(r *http.Request, trustProxy bool) string { + if r == nil { + return "" + } + host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr)) + if err != nil { + host = strings.TrimSpace(r.RemoteAddr) + } + peer := net.ParseIP(host) + if trustProxy && peer != nil && peer.IsLoopback() { + for _, candidate := range strings.Split(r.Header.Get("X-Forwarded-For"), ",") { + candidate = strings.TrimSpace(candidate) + if ip := net.ParseIP(candidate); ip != nil { + return ip.String() + } + } + } + if peer != nil { + return peer.String() + } + return "" +} + +func resourceIDFromPath(value string) string { + parts := strings.Split(strings.Trim(value, "/"), "/") + for i := len(parts) - 1; i >= 0; i-- { + if positiveID(parts[i]) { + return parts[i] + } + } + return "" +} + +func (a *app) health(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + a.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + a.writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "plugin_id": pluginID, "version": pluginVersion}) +} + +func (a *app) ready(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + a.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + a.writeJSON(w, http.StatusOK, map[string]any{"status": "ready", "plugin_id": pluginID, "version": pluginVersion}) +} + +func (a *app) auditLog(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + a.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + _, s, _, ok := a.authenticate(w, r) + if !ok { + return + } + a.mu.Lock() + items := append([]auditEvent(nil), a.audit...) + a.mu.Unlock() + a.writeJSON(w, http.StatusOK, map[string]any{"items": items, "operator": s.user}) +} + +func (a *app) static(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/admin" { + http.Redirect(w, r, a.basePath()+"/admin/", http.StatusPermanentRedirect) + return + } + if r.URL.Path == "/" || r.URL.Path == "/admin/" { + data, err := uiFS.ReadFile("ui/index.html") + if err != nil { + http.Error(w, "ui unavailable", http.StatusInternalServerError) + return + } + w.Header().Set("Cache-Control", "no-store") + base := html.EscapeString(a.basePath()) + data = []byte(strings.ReplaceAll(string(data), "__PLUGIN_BASE_PATH__", base)) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(data) + return + } + for _, name := range []string{"ui/app.js", "ui/styles.css"} { + if r.URL.Path == "/"+strings.TrimPrefix(name, "ui/") { + data, err := uiFS.ReadFile(name) + if err != nil { + http.NotFound(w, r) + return + } + if strings.HasSuffix(name, ".js") { + w.Header().Set("Content-Type", "text/javascript; charset=utf-8") + } else { + w.Header().Set("Content-Type", "text/css; charset=utf-8") + } + _, _ = w.Write(data) + return + } + } + http.NotFound(w, r) +} + +func (a *app) routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/healthz", a.health) + mux.HandleFunc("/readyz", a.ready) + mux.HandleFunc("/login", a.login) + mux.HandleFunc("/login/2fa", a.login2FA) + mux.HandleFunc("/logout", a.logout) + mux.HandleFunc("/api/me", a.me) + mux.HandleFunc("/api/status", a.status) + mux.HandleFunc("/api/captcha-config", a.captchaConfig) + mux.HandleFunc("/api/audit", a.auditLog) + mux.Handle("/api/plans", a.readProxy("/api/v1/admin/payment/plans")) + mux.Handle("/api/subscriptions", a.readProxy("/api/v1/admin/subscriptions")) + mux.HandleFunc("/api/subscriptions/", func(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/subscriptions/") + if !positiveID(id) || strings.Contains(id, "/") { + http.NotFound(w, r) + return + } + a.readProxy("/api/v1/admin/subscriptions/"+id)(w, r) + }) + mux.HandleFunc("/api/users/", func(w http.ResponseWriter, r *http.Request) { + rest := strings.TrimPrefix(r.URL.Path, "/api/users/") + parts := strings.Split(rest, "/") + if len(parts) == 1 { + a.userProxy(w, r) + return + } + if len(parts) != 2 || !positiveID(parts[0]) || parts[1] != "subscriptions" { + http.NotFound(w, r) + return + } + a.readProxy("/api/v1/admin/users/"+parts[0]+"/subscriptions")(w, r) + }) + mux.HandleFunc("/", a.static) + return requestIDMiddleware(a.securityHeaders(mux)) +} + +func (a *app) securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + ancestors := strings.Join(a.frameAncestors, " ") + if ancestors == "'self'" { + w.Header().Set("X-Frame-Options", "SAMEORIGIN") + } + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-ancestors "+ancestors+"; base-uri 'self'; form-action 'self'") + next.ServeHTTP(w, r) + }) +} + +func envelopeData(envelope coreEnvelope) map[string]any { + var value map[string]any + if len(envelope.Data) > 0 && json.Unmarshal(envelope.Data, &value) == nil && value != nil { + return value + } + return map[string]any{} +} + +func isAdmin(user map[string]any) bool { + role, _ := user["role"].(string) + return strings.EqualFold(role, "admin") || strings.EqualFold(role, "administrator") +} + +func publicUser(user map[string]any) map[string]any { + allowed := []string{"id", "email", "username", "role", "status"} + out := make(map[string]any, len(allowed)) + for _, key := range allowed { + if value, ok := user[key]; ok { + out[key] = value + } + } + return out +} + +func (a *app) writeCoreEnvelope(w http.ResponseWriter, envelope coreEnvelope) { + var data any + if len(envelope.Data) > 0 { + if json.Unmarshal(envelope.Data, &data) != nil { + a.writeJSON(w, http.StatusBadGateway, map[string]string{"error": "invalid core response"}) + return + } + } + data = sanitizeValue(data) + status := envelope.status + if status == 0 { + status = http.StatusOK + } + a.writeJSON(w, status, map[string]any{"code": envelope.Code, "message": envelope.Message, "data": data}) +} + +func sanitizeValue(value any) any { + switch typed := value.(type) { + case []any: + out := make([]any, 0, len(typed)) + for _, item := range typed { + out = append(out, sanitizeValue(item)) + } + return out + case map[string]any: + out := make(map[string]any, len(typed)) + for key, item := range typed { + if sensitiveResponseKey(key) { + continue + } + out[key] = sanitizeValue(item) + } + return out + default: + return value + } +} + +func sensitiveResponseKey(key string) bool { + key = strings.ToLower(key) + key = strings.NewReplacer("_", "", "-", "", " ", "").Replace(key) + switch key { + case "accesstoken", "refreshtoken", "idtoken", "token", "authorization", "apikey", "xapikey", "password", "passwordhash", "secret", "clientsecret", "privatekey", "signingkey", "cookie", "session": + return true + default: + return false + } +} + +func (a *app) coreError(w http.ResponseWriter, err error, fallback string) { + status := http.StatusBadGateway + if ce, ok := err.(*coreError); ok && ce.status >= 400 && ce.status < 500 { + status = ce.status + } + // Deliberately do not relay Core response bodies or token-bearing messages. + a.writeJSON(w, status, map[string]string{"error": fallback}) +} + +func main() { + host := os.Getenv("PLUGIN_HOST") + if host == "" { + host = "127.0.0.1" + } + port := os.Getenv("PLUGIN_PORT") + if port == "" { + port = "8091" + } + coreURL := os.Getenv("CORE_BASE_URL") + if coreURL == "" { + coreURL = "http://127.0.0.1:8080" + } + core, err := newCoreClient(coreURL) + if err != nil { + slog.Error("invalid Core URL", "error", err) + os.Exit(2) + } + secure := strings.EqualFold(strings.TrimSpace(os.Getenv("PLUGIN_COOKIE_SECURE")), "true") + if !isLoopbackHost(host) && !secure { + slog.Error("PLUGIN_COOKIE_SECURE must be true when PLUGIN_HOST is not loopback") + os.Exit(2) + } + sameSite, err := parseSameSite(os.Getenv("PLUGIN_COOKIE_SAMESITE")) + if err != nil { + slog.Error("invalid PLUGIN_COOKIE_SAMESITE", "error", err) + os.Exit(2) + } + basePath := normalizeBasePath(os.Getenv("PLUGIN_PUBLIC_BASE_PATH")) + cookiePath := strings.TrimSpace(os.Getenv("PLUGIN_COOKIE_PATH")) + if cookiePath == "" { + cookiePath = basePath + } + if sameSite == http.SameSiteNoneMode && !secure { + slog.Error("PLUGIN_COOKIE_SAMESITE=none requires PLUGIN_COOKIE_SECURE=true") + os.Exit(2) + } + ancestors := parseFrameAncestors(os.Getenv("PLUGIN_FRAME_ANCESTORS")) + a := newAppWithConfig(core, appConfig{ + CookieSecure: secure, + CookieSameSite: sameSite, + CookiePath: cookiePath, + PublicBasePath: basePath, + FrameAncestors: ancestors, + TrustProxy: strings.EqualFold(strings.TrimSpace(os.Getenv("PLUGIN_TRUST_PROXY")), "true"), + }) + srv := &http.Server{Addr: host + ":" + port, Handler: a.routes(), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 20 * time.Second, IdleTimeout: 60 * time.Second} + slog.Info("subscription-admin listening", "addr", srv.Addr) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + slog.Error("subscription-admin stopped", "error", err) + os.Exit(1) + } +} + +func parseSameSite(value string) (http.SameSite, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "lax": + return http.SameSiteLaxMode, nil + case "strict": + return http.SameSiteStrictMode, nil + case "none": + return http.SameSiteNoneMode, nil + default: + return 0, errors.New("must be lax, strict, or none") + } +} + +func parseFrameAncestors(value string) []string { + fields := strings.Fields(value) + if len(fields) == 0 { + return []string{"'self'"} + } + valid := make([]string, 0, len(fields)) + for _, field := range fields { + if field == "'self'" || field == "'none'" { + valid = append(valid, field) + continue + } + u, err := url.Parse(field) + if err == nil && (u.Scheme == "https" || u.Scheme == "http") && u.Host != "" && u.Path == "" && u.RawQuery == "" && u.Fragment == "" && u.User == nil { + valid = append(valid, field) + } + } + if len(valid) == 0 { + return []string{"'self'"} + } + return valid +} diff --git a/plugins/subscription-admin/main_test.go b/plugins/subscription-admin/main_test.go new file mode 100644 index 0000000..b10ffc6 --- /dev/null +++ b/plugins/subscription-admin/main_test.go @@ -0,0 +1,369 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +func testCoreClient(t *testing.T, handler http.Handler) *coreClient { + t.Helper() + ts := httptest.NewServer(handler) + t.Cleanup(ts.Close) + c, err := newCoreClient(ts.URL) + if err != nil { + t.Fatal(err) + } + return c +} + +func TestCoreClientAllowlistAndRequestID(t *testing.T) { + var gotPath, gotAuth, gotRequestID string + c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath, gotAuth, gotRequestID = r.URL.RequestURI(), r.Header.Get("Authorization"), r.Header.Get("X-Request-Id") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"role":"admin"}}`)) + })) + if _, err := c.read(context.Background(), "/api/v1/admin/subscriptions?page=1&evil=ignored", "CORE-TOKEN"); err != nil { + t.Fatal(err) + } + if gotPath != "/api/v1/admin/subscriptions?page=1" { + t.Fatalf("path=%q", gotPath) + } + if gotAuth != "Bearer CORE-TOKEN" || gotRequestID == "" { + t.Fatalf("headers auth=%q request_id=%q", gotAuth, gotRequestID) + } + if _, err := c.read(context.Background(), "/api/v1/admin/payment/plans/1", "TOKEN"); err == nil { + t.Fatal("unexpected allowlist success") + } +} + +func TestCoreReadQueryIsSanitized(t *testing.T) { + var gotPath string + c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.RequestURI() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":0,"message":"success","data":[]}`)) + })) + if _, err := c.read(context.Background(), "/api/v1/admin/subscriptions?page=1&evil=ignored", "TOKEN"); err != nil { + t.Fatal(err) + } + if gotPath != "/api/v1/admin/subscriptions?page=1" { + t.Fatalf("sanitized path=%q", gotPath) + } +} + +func TestNewCoreClientRejectsNonAbsoluteOrQueryURL(t *testing.T) { + for _, base := range []string{"", "/api", "ftp://core", "https://core.test/?token=secret", "http://core.test", "https://user:pass@core.test"} { + if _, err := newCoreClient(base); err == nil { + t.Fatalf("expected invalid Core URL: %q", base) + } + } + for _, base := range []string{"http://127.0.0.1:8080", "http://[::1]:8080", "http://localhost:8080"} { + if _, err := newCoreClient(base); err != nil { + t.Fatalf("expected loopback URL to be accepted: %q: %v", base, err) + } + } +} + +func TestCoreClientRejectsNonzeroEnvelopeCode(t *testing.T) { + c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":422,"message":"bad","data":{}}`)) + })) + if _, err := c.read(context.Background(), "/api/v1/admin/subscriptions", "TOKEN"); err == nil { + t.Fatal("expected nonzero Core envelope to fail") + } +} + +func TestLoginRequiresAdminAndDoesNotReturnCoreToken(t *testing.T) { + c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/auth/login": + _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"access_token":"CORE-TOKEN","refresh_token":"CORE-REFRESH"}}`)) + case "/api/v1/auth/me": + _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":7,"role":"user","email":"user@example.com"}}`)) + case "/api/v1/auth/logout": + _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`)) + default: + t.Errorf("unexpected Core path %s", r.URL.Path) + } + })) + a := newApp(c, false) + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"email":"user@example.com","password":"password"}`)) + rec := httptest.NewRecorder() + a.login(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "CORE-TOKEN") || strings.Contains(rec.Body.String(), "CORE-REFRESH") { + t.Fatalf("core token leaked: %s", rec.Body.String()) + } +} + +func TestLoginAndReadProxyUsePluginCookie(t *testing.T) { + var readAuth string + c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/auth/login": + _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"access_token":"CORE-TOKEN","refresh_token":"CORE-REFRESH"}}`)) + case "/api/v1/auth/me": + _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":1,"role":"admin","email":"admin@example.com"}}`)) + case "/api/v1/admin/subscriptions": + readAuth = r.Header.Get("Authorization") + _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"items":[],"total":0,"page":1,"page_size":20,"pages":1}}`)) + default: + t.Errorf("unexpected Core path %s", r.URL.Path) + } + })) + a := newApp(c, false) + loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"email":"admin@example.com","password":"password"}`)) + loginRec := httptest.NewRecorder() + a.login(loginRec, loginReq) + if loginRec.Code != http.StatusOK || strings.Contains(loginRec.Body.String(), "CORE-TOKEN") { + t.Fatalf("login status/body: %d %s", loginRec.Code, loginRec.Body.String()) + } + cookie := loginRec.Result().Cookies()[0] + readReq := httptest.NewRequest(http.MethodGet, "/api/subscriptions?page=1", nil) + readReq.AddCookie(cookie) + readRec := httptest.NewRecorder() + a.readProxy("/api/v1/admin/subscriptions")(readRec, readReq) + if readRec.Code != http.StatusOK || readAuth != "Bearer CORE-TOKEN" { + t.Fatalf("read status=%d auth=%q body=%s", readRec.Code, readAuth, readRec.Body.String()) + } +} + +func TestReadProxyStripsSensitiveCoreFields(t *testing.T) { + c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/auth/me": + _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":1,"role":"admin"}}`)) + case "/api/v1/admin/subscriptions": + _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"access_token":"LEAK","items":[{"refresh_token":"LEAK2","id":1}]}}`)) + default: + _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`)) + } + })) + a := newApp(c, false) + a.sessions["sid"] = session{accessToken: "TOKEN", csrfToken: "CSRF", createdAt: time.Now(), lastSeen: time.Now(), user: map[string]any{"id": 1}} + req := httptest.NewRequest(http.MethodGet, "/api/subscriptions", nil) + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"}) + rec := httptest.NewRecorder() + a.readProxy("/api/v1/admin/subscriptions")(rec, req) + if strings.Contains(rec.Body.String(), "LEAK") || strings.Contains(rec.Body.String(), "refresh_token") { + t.Fatalf("sensitive field leaked: %s", rec.Body.String()) + } +} + +func TestCoreRevocationDestroysPluginSession(t *testing.T) { + c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api/v1/auth/me" { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"code":401,"message":"revoked"}`)) + return + } + _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`)) + })) + a := newApp(c, false) + now := time.Now() + a.sessions["sid"] = session{accessToken: "TOKEN", csrfToken: "CSRF", createdAt: now, lastSeen: now, user: map[string]any{"id": 1}} + req := httptest.NewRequest(http.MethodGet, "/api/me", nil) + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"}) + rec := httptest.NewRecorder() + a.me(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if _, ok := a.sessions["sid"]; ok { + t.Fatal("revoked Core session remained in plugin store") + } +} + +func TestLogoutRevokesCoreRefreshToken(t *testing.T) { + var logoutCalls int32 + c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api/v1/auth/logout" { + atomic.AddInt32(&logoutCalls, 1) + var body map[string]string + _ = json.NewDecoder(r.Body).Decode(&body) + if body["refresh_token"] != "REFRESH" { + t.Errorf("refresh token=%q", body["refresh_token"]) + } + } + _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`)) + })) + a := newApp(c, false) + a.sessions["sid"] = session{accessToken: "TOKEN", refreshToken: "REFRESH", csrfToken: "CSRF", createdAt: time.Now(), lastSeen: time.Now(), user: map[string]any{"id": 1}} + a.sessionLocks["sid"] = &sync.Mutex{} + req := httptest.NewRequest(http.MethodPost, "/logout", nil) + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"}) + req.Header.Set("X-CSRF-Token", "CSRF") + rec := httptest.NewRecorder() + a.logout(rec, req) + if rec.Code != http.StatusOK || atomic.LoadInt32(&logoutCalls) != 1 { + t.Fatalf("status=%d logout_calls=%d body=%s", rec.Code, logoutCalls, rec.Body.String()) + } +} + +func TestReadProxyRefreshesAtMostOncePerRequest(t *testing.T) { + var refreshCalls int32 + var meCalls int32 + var readCalls int32 + c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/auth/me": + call := atomic.AddInt32(&meCalls, 1) + if call == 1 { + _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":1,"role":"admin"}}`)) + } else { + _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":1,"role":"admin"}}`)) + } + case "/api/v1/admin/subscriptions": + call := atomic.AddInt32(&readCalls, 1) + if call == 1 { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"code":401,"message":"expired"}`)) + } else { + _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"items":[]}}`)) + } + case "/api/v1/auth/refresh": + atomic.AddInt32(&refreshCalls, 1) + _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"access_token":"NEW","refresh_token":"NEW-REFRESH"}}`)) + default: + t.Errorf("unexpected Core path %s", r.URL.Path) + } + })) + a := newApp(c, false) + now := time.Now() + a.sessions["sid"] = session{accessToken: "TOKEN", refreshToken: "REFRESH", csrfToken: "CSRF", createdAt: now, lastSeen: now, user: map[string]any{"id": 1}} + a.sessionLocks["sid"] = &sync.Mutex{} + req := httptest.NewRequest(http.MethodGet, "/api/subscriptions", nil) + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"}) + rec := httptest.NewRecorder() + a.readProxy("/api/v1/admin/subscriptions")(rec, req) + if rec.Code != http.StatusOK || atomic.LoadInt32(&refreshCalls) != 1 { + t.Fatalf("status=%d refresh_calls=%d body=%s", rec.Code, refreshCalls, rec.Body.String()) + } +} + +func TestSessionExpiry(t *testing.T) { + now := time.Now() + a := newApp(nil, false) + a.clock = func() time.Time { return now } + a.sessions["sid"] = session{accessToken: "TOKEN", csrfToken: "CSRF", createdAt: now, lastSeen: now.Add(-sessionTTL - time.Second), user: map[string]any{"id": 1}} + req := httptest.NewRequest(http.MethodGet, "/api/me", nil) + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"}) + rec := httptest.NewRecorder() + a.me(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestTwoFactorPendingTokenIsSingleUse(t *testing.T) { + now := time.Now() + a := newApp(nil, false) + a.clock = func() time.Time { return now } + a.pending["pending"] = pendingLogin{tempToken: "CORE-TEMP", expires: now.Add(time.Minute)} + first := a.pending["pending"] + delete(a.pending, "pending") + if _, ok := a.pending["pending"]; ok || first.tempToken != "CORE-TEMP" { + t.Fatal("pending token was not consumed") + } +} + +func TestAllowedReadPathRejectsTraversalAndUnknownRoutes(t *testing.T) { + for _, path := range []string{ + "/api/v1/admin/subscriptions/1/progress", + "/api/v1/admin/users/1/subscriptions/extra", + "/api/v1/admin/payment/plans/1", + "/api/v1/admin/../users", + } { + if allowedReadPath(path) { + t.Fatalf("unexpected allowlist match: %s", path) + } + } +} + +func TestRoutesProtectReadOnlyEndpointsAndSetSecurityHeaders(t *testing.T) { + a := newApp(nil, false) + server := httptest.NewServer(a.routes()) + t.Cleanup(server.Close) + response, err := server.Client().Get(server.URL + "/api/plans") + if err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusUnauthorized { + t.Fatalf("status=%d", response.StatusCode) + } + if response.Header.Get("Content-Security-Policy") == "" || response.Header.Get("X-Content-Type-Options") != "nosniff" { + t.Fatalf("security headers missing: %#v", response.Header) + } +} + +func TestRoutesPropagateRequestIDAndBootstrapSession(t *testing.T) { + var coreRequestID string + c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + coreRequestID = r.Header.Get(requestIDHeader) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/auth/login": + _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"access_token":"A","refresh_token":"R"}}`)) + case "/api/v1/auth/me": + _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":1,"role":"admin","email":"a@example.com"}}`)) + case "/api/v1/auth/logout": + _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`)) + default: + _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`)) + } + })) + a := newApp(c, false) + server := httptest.NewServer(a.routes()) + t.Cleanup(server.Close) + request, _ := http.NewRequest(http.MethodPost, server.URL+"/login", strings.NewReader(`{"email":"a@example.com","password":"password"}`)) + request.Header.Set(requestIDHeader, "client-request-123") + request.Header.Set("Content-Type", "application/json") + response, err := server.Client().Do(request) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusOK || response.Header.Get(requestIDHeader) != "client-request-123" || coreRequestID != "client-request-123" { + t.Fatalf("status=%d response_id=%q core_id=%q", response.StatusCode, response.Header.Get(requestIDHeader), coreRequestID) + } + cookies := response.Cookies() + if len(cookies) != 1 || !cookies[0].HttpOnly || cookies[0].SameSite != http.SameSiteLaxMode { + t.Fatalf("cookie=%#v", cookies) + } + bootstrap, _ := http.NewRequest(http.MethodGet, server.URL+"/api/me", nil) + bootstrap.AddCookie(cookies[0]) + bootstrapResponse, err := server.Client().Do(bootstrap) + if err != nil { + t.Fatal(err) + } + if bootstrapResponse.StatusCode != http.StatusOK || !strings.Contains(readBody(t, bootstrapResponse), "csrf_token") { + t.Fatalf("bootstrap status=%d", bootstrapResponse.StatusCode) + } +} + +func readBody(t *testing.T, response *http.Response) string { + t.Helper() + defer response.Body.Close() + data, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + return string(data) +} diff --git a/plugins/subscription-admin/package.sh b/plugins/subscription-admin/package.sh new file mode 100755 index 0000000..148210f --- /dev/null +++ b/plugins/subscription-admin/package.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env sh +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +OUT=${OUT:-"$ROOT/dist/qiu.subscription-admin.s2plugin"} +command -v jq >/dev/null 2>&1 || { printf '%s\n' 'jq is required to build a package' >&2; exit 2; } +TMP=$(mktemp -d) +cleanup() { + rm -rf "$TMP" +} +trap cleanup EXIT INT TERM + +mkdir -p "$TMP/ui" "$(dirname -- "$OUT")" +cp "$ROOT/ui/index.html" "$ROOT/ui/app.js" "$ROOT/ui/styles.css" "$TMP/ui/" + +# Recompute declared hashes at package time so a UI change cannot produce an +# archive that the control plane rejects. The repository descriptor remains +# the human-readable source contract. +INDEX_HASH=$(shasum -a 256 "$ROOT/ui/index.html" | awk '{print $1}') +APP_HASH=$(shasum -a 256 "$ROOT/ui/app.js" | awk '{print $1}') +STYLES_HASH=$(shasum -a 256 "$ROOT/ui/styles.css" | awk '{print $1}') +jq --arg index_hash "$INDEX_HASH" --arg app_hash "$APP_HASH" --arg styles_hash "$STYLES_HASH" \ + '.files["ui/index.html"]=$index_hash | .files["ui/app.js"]=$app_hash | .files["ui/styles.css"]=$styles_hash' \ + "$ROOT/business-plugin-manifest.v1.json" > "$TMP/manifest.json" + +if [ -n "${SIGNATURE_FILE:-}" ]; then + cp "$SIGNATURE_FILE" "$TMP/signature.json" +fi + +OUT_DIR=$(CDPATH= cd -- "$(dirname -- "$OUT")" && pwd) +OUT_PATH="$OUT_DIR/$(basename -- "$OUT")" +(cd "$TMP" && zip -q -r "$OUT_PATH" manifest.json ui) +printf '%s\n' "$OUT_PATH" diff --git a/plugins/subscription-admin/test/browser-check.mjs b/plugins/subscription-admin/test/browser-check.mjs new file mode 100644 index 0000000..26b8d2d --- /dev/null +++ b/plugins/subscription-admin/test/browser-check.mjs @@ -0,0 +1,43 @@ +import { chromium } from 'playwright' +import fs from 'node:fs/promises' +import path from 'node:path' + +const origin = process.env.PLUGIN_BROWSER_ORIGIN || 'http://127.0.0.1:18081' +const entry = `${origin}/extensions/qiu.subscription-admin/admin/` +const outputDir = process.env.PLUGIN_SCREENSHOT_DIR || '.playwright-cli/subscription-admin' + +await fs.mkdir(path.resolve(outputDir), { recursive: true }) + +const browser = await chromium.launch({ headless: true }) +try { + for (const width of [425, 900, 1440]) { + const page = await browser.newPage({ viewport: { width, height: 900 }, deviceScaleFactor: 1 }) + const responseLeaks = [] + page.on('response', async (response) => { + if (!response.headers()['content-type']?.includes('application/json')) return + try { + const body = await response.text() + if (/access_token|refresh_token|admin[_-]?api[_-]?key|password|client_secret/i.test(body)) responseLeaks.push(response.url()) + } catch (_) {} + }) + await page.goto(entry, { waitUntil: 'domcontentloaded' }) + await page.getByLabel('管理员邮箱').fill('admin@example.com') + await page.getByLabel('密码').fill('password') + await page.getByRole('button', { name: '登录' }).click() + await page.locator('#app-view').waitFor({ state: 'visible' }) + await page.waitForTimeout(50) + if (responseLeaks.length) throw new Error(`sensitive response field exposed at ${width}px: ${responseLeaks.join(', ')}`) + await page.getByRole('button', { name: '用户订阅' }).click() + await page.locator('#subscriptions-list table').waitFor() + await page.getByRole('button', { name: '下一页' }).click() + await page.getByRole('button', { name: '概览' }).click() + await page.reload({ waitUntil: 'domcontentloaded' }) + await page.locator('#app-view').waitFor({ state: 'visible' }) + const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth) + if (overflow) throw new Error(`horizontal overflow at ${width}px`) + await page.screenshot({ path: path.join(outputDir, `subscription-admin-${width}.png`), fullPage: true }) + await page.close() + } +} finally { + await browser.close() +} diff --git a/plugins/subscription-admin/test/mock-core.mjs b/plugins/subscription-admin/test/mock-core.mjs new file mode 100644 index 0000000..f6e36b1 --- /dev/null +++ b/plugins/subscription-admin/test/mock-core.mjs @@ -0,0 +1,80 @@ +import http from 'node:http' + +const port = Number(process.env.MOCK_CORE_PORT || 18080) +const token = process.env.MOCK_ACCESS_TOKEN || 'MOCK-ACCESS' +const refresh = process.env.MOCK_REFRESH_TOKEN || 'MOCK-REFRESH' +const users = new Map([ + ['admin@example.com', { id: 1, role: 'admin', email: 'admin@example.com', username: 'admin' }], + ['user@example.com', { id: 2, role: 'user', email: 'user@example.com', username: 'user' }] +]) + +function json(res, status, body) { + res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }) + res.end(JSON.stringify(body)) +} + +async function body(req) { + const chunks = [] + for await (const chunk of req) chunks.push(chunk) + return chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {} +} + +function authorized(req) { + return req.headers.authorization === `Bearer ${token}` || req.headers.authorization === `Bearer NEW-MOCK-ACCESS` +} + +function subscriptions(page = 1, pageSize = 50) { + const all = Array.from({ length: 57 }, (_, index) => ({ + id: index + 1, + user_id: 1, + plan_id: 10 + (index % 2), + plan_name: index % 2 ? '专业版' : '基础版', + status: 'active', + starts_at: '2026-08-01T00:00:00Z', + expires_at: '2026-09-01T00:00:00Z', + cycle_usage_usd: index / 100, + cycle_quota_usd: 100, + user: { id: 1, username: 'admin', email: 'admin@example.com', balance: 123.45 } + })) + const start = (page - 1) * pageSize + return { items: all.slice(start, start + pageSize), total: all.length, page, page_size: pageSize, pages: Math.ceil(all.length / pageSize) } +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`) + const path = url.pathname + if (req.method === 'POST' && path === '/api/v1/auth/login') { + const input = await body(req) + const user = users.get(input.email) + if (!user || input.password !== 'password') return json(res, 401, { code: 401, message: 'invalid credentials' }) + return json(res, 200, { code: 0, message: 'success', data: { access_token: token, refresh_token: refresh, user } }) + } + if (req.method === 'POST' && path === '/api/v1/auth/refresh') { + const input = await body(req) + if (input.refresh_token !== refresh && input.refresh_token !== 'NEW-MOCK-REFRESH') return json(res, 401, { code: 401, message: 'expired' }) + return json(res, 200, { code: 0, message: 'success', data: { access_token: 'NEW-MOCK-ACCESS', refresh_token: 'NEW-MOCK-REFRESH' } }) + } + if (req.method === 'POST' && path === '/api/v1/auth/logout') return json(res, 200, { code: 0, message: 'success', data: {} }) + if (req.method === 'GET' && path === '/api/v1/settings/public') return json(res, 200, { code: 0, message: 'success', data: { turnstile_enabled: false, geetest_captcha_enabled: false, tencent_captcha_enabled: false, aliyun_captcha_enabled: false } }) + if (!authorized(req)) return json(res, 401, { code: 401, message: 'unauthorized' }) + if (req.method === 'GET' && path === '/api/v1/auth/me') return json(res, 200, { code: 0, message: 'success', data: users.get('admin@example.com') }) + if (req.method === 'GET' && path === '/api/v1/admin/payment/plans') { + return json(res, 200, { code: 0, message: 'success', data: [{ id: 10, name: '基础版', price: 9.9, currency: 'USD', validity_days: 30, validity_unit: 'day', for_sale: true, included_groups: [] }, { id: 11, name: '专业版', price: 19.9, currency: 'USD', validity_days: 30, validity_unit: 'day', for_sale: true, included_groups: [] }] }) + } + if (req.method === 'GET' && path === '/api/v1/admin/subscriptions') { + const page = Number(url.searchParams.get('page') || 1) + const pageSize = Number(url.searchParams.get('page_size') || 50) + return json(res, 200, { code: 0, message: 'success', data: subscriptions(page, pageSize) }) + } + const subscriptionMatch = path.match(/^\/api\/v1\/admin\/subscriptions\/(\d+)$/) + if (req.method === 'GET' && subscriptionMatch) return json(res, 200, { code: 0, message: 'success', data: subscriptions(1, 1).items[0] }) + const userSubscriptionsMatch = path.match(/^\/api\/v1\/admin\/users\/(\d+)\/subscriptions$/) + if (req.method === 'GET' && userSubscriptionsMatch) return json(res, 200, { code: 0, message: 'success', data: subscriptions(1, 2).items }) + const userMatch = path.match(/^\/api\/v1\/admin\/users\/(\d+)$/) + if (req.method === 'GET' && userMatch) return json(res, 200, { code: 0, message: 'success', data: { id: Number(userMatch[1]), username: 'admin', email: 'admin@example.com', balance: 123.45, frozen_balance: 0 } }) + return json(res, 404, { code: 404, message: 'not found' }) +}) + +server.listen(port, '127.0.0.1', () => { + process.stdout.write(`mock core listening on 127.0.0.1:${port}\n`) +}) diff --git a/plugins/subscription-admin/test/mock-proxy.mjs b/plugins/subscription-admin/test/mock-proxy.mjs new file mode 100644 index 0000000..2a0e7b4 --- /dev/null +++ b/plugins/subscription-admin/test/mock-proxy.mjs @@ -0,0 +1,27 @@ +import http from 'node:http' + +const prefix = '/extensions/qiu.subscription-admin' +const targetPort = Number(process.env.PLUGIN_TARGET_PORT || 18082) +const port = Number(process.env.MOCK_PROXY_PORT || 18081) + +const server = http.createServer((req, res) => { + if (!req.url.startsWith(prefix)) { + res.writeHead(404) + res.end('not found') + return + } + const forwardedPath = req.url.slice(prefix.length) || '/' + const proxy = http.request({ hostname: '127.0.0.1', port: targetPort, method: req.method, path: forwardedPath, headers: { ...req.headers, host: `127.0.0.1:${targetPort}` } }, (upstream) => { + const headers = { ...upstream.headers } + if (headers.location && headers.location.startsWith('/admin/')) headers.location = `${prefix}${headers.location}` + res.writeHead(upstream.statusCode || 502, headers) + upstream.pipe(res) + }) + proxy.on('error', () => { + if (!res.headersSent) res.writeHead(502) + res.end('proxy error') + }) + req.pipe(proxy) +}) + +server.listen(port, '127.0.0.1', () => process.stdout.write(`mock proxy listening on 127.0.0.1:${port}\n`)) diff --git a/plugins/subscription-admin/test/run-browser-check.sh b/plugins/subscription-admin/test/run-browser-check.sh new file mode 100755 index 0000000..66e5a73 --- /dev/null +++ b/plugins/subscription-admin/test/run-browser-check.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env sh +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cleanup() { + kill "${PROXY_PID:-}" "${PLUGIN_PID:-}" "${CORE_PID:-}" 2>/dev/null || true +} +trap cleanup EXIT INT TERM + +MOCK_CORE_PORT=18080 node "$ROOT/test/mock-core.mjs" >/tmp/subscription-admin-mock-core.log 2>&1 & CORE_PID=$! +CORE_BASE_URL=http://127.0.0.1:18080 \ +PLUGIN_HOST=127.0.0.1 \ +PLUGIN_PORT=18082 \ +PLUGIN_PUBLIC_BASE_PATH=/extensions/qiu.subscription-admin \ +PLUGIN_COOKIE_PATH=/extensions/qiu.subscription-admin/ \ +PLUGIN_COOKIE_SECURE=false \ +go run "$ROOT" >/tmp/subscription-admin-plugin.log 2>&1 & PLUGIN_PID=$! +PLUGIN_TARGET_PORT=18082 MOCK_PROXY_PORT=18081 node "$ROOT/test/mock-proxy.mjs" >/tmp/subscription-admin-mock-proxy.log 2>&1 & PROXY_PID=$! + +sleep 2 +PLUGIN_BROWSER_ORIGIN=http://127.0.0.1:18081 PLUGIN_SCREENSHOT_DIR="$ROOT/.screenshots/subscription-admin" node "$ROOT/test/browser-check.mjs" diff --git a/plugins/subscription-admin/tools/manifestcheck/main.go b/plugins/subscription-admin/tools/manifestcheck/main.go new file mode 100644 index 0000000..4f45798 --- /dev/null +++ b/plugins/subscription-admin/tools/manifestcheck/main.go @@ -0,0 +1,45 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "git.awaioi.com/awaioi/sub2api-add/plugins/subscription-admin/internal/manifest" +) + +func main() { + manifestPath := flag.String("manifest", "business-plugin-manifest.v1.json", "manifest path") + signaturePath := flag.String("signature", "", "optional detached signature path") + publicKeyPath := flag.String("public-key", "", "base64 Ed25519 public key file") + flag.Parse() + m, raw, err := manifest.Load(*manifestPath) + if err != nil { + fatal(err) + } + if (*signaturePath == "") != (*publicKeyPath == "") { + fatal(fmt.Errorf("-signature and -public-key must be provided together")) + } + if *signaturePath != "" { + signature, err := os.ReadFile(*signaturePath) + if err != nil { + fatal(err) + } + publicKey, err := os.ReadFile(*publicKeyPath) + if err != nil { + fatal(err) + } + if err := manifest.VerifyKeyID(signature, m.Publisher.KeyID); err != nil { + fatal(err) + } + if err := manifest.VerifySignature(raw, signature, publicKey); err != nil { + fatal(err) + } + } + fmt.Printf("manifest valid: %s\n", *manifestPath) +} + +func fatal(err error) { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) +} diff --git a/plugins/subscription-admin/ui/app.js b/plugins/subscription-admin/ui/app.js new file mode 100644 index 0000000..8be86fe --- /dev/null +++ b/plugins/subscription-admin/ui/app.js @@ -0,0 +1,170 @@ +(() => { + 'use strict' + + const state = { + csrf: '', + plans: [], + subscriptions: null, + subscriptionPage: 1, + subscriptionPageSize: 50, + captcha: { enabled: false, provider: '' } + } + const $ = (selector) => document.querySelector(selector) + const loginView = $('#login-view') + const appView = $('#app-view') + const loginForm = $('#login-form') + const basePath = (document.body.dataset.basePath || '').replace(/\/$/, '') + const route = (path) => `${basePath}${path}` + + function showError(message, target = $('#app-error')) { + target.textContent = message || '请求失败' + target.hidden = !message + } + function clearError(target = $('#app-error')) { target.textContent = ''; target.hidden = true } + + async function request(path, options = {}) { + const headers = new Headers(options.headers || {}) + headers.set('Accept', 'application/json') + if (options.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json') + if (state.csrf && options.method && options.method !== 'GET') headers.set('X-CSRF-Token', state.csrf) + const response = await fetch(route(path), { ...options, headers, credentials: 'same-origin' }) + const payload = await response.json().catch(() => ({})) + if (!response.ok) { + if (response.status === 401) { state.csrf = ''; loginView.hidden = false; appView.hidden = true } + throw new Error(payload.error || '请求失败') + } + return payload + } + + function unwrap(payload) { return payload && payload.code === 0 && Object.prototype.hasOwnProperty.call(payload, 'data') ? payload.data : payload } + function formatNumber(value) { + if (value === null || value === undefined || value === '') return '-' + const number = Number(value) + return Number.isFinite(number) ? number.toLocaleString('zh-CN', { maximumFractionDigits: 6 }) : String(value) + } + function formatDate(value) { + if (!value) return '-' + const date = new Date(value) + return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('zh-CN', { dateStyle: 'medium', timeStyle: 'short' }) + } + function escapeHTML(value) { return String(value ?? '').replace(/[&<>'"]/g, (character) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[character])) } + function statusLabel(value) { const safe = String(value || 'unknown'); return `${escapeHTML(safe)}` } + function setView(name) { + document.querySelectorAll('.tab').forEach((button) => button.classList.toggle('active', button.dataset.view === name)) + document.querySelectorAll('.view').forEach((view) => { view.hidden = view.id !== `view-${name}` }) + if (name === 'plans' && !state.plans.length) loadPlans() + if (name === 'subscriptions' && !state.subscriptions) loadSubscriptions() + if (name === 'audit') loadAudit() + } + + async function login(event) { + event.preventDefault(); clearError($('#login-error')) + const form = new FormData(loginForm); const button = loginForm.querySelector('button[type="submit"]'); button.disabled = true + try { + const captchaToken = String(form.get('captcha_token') || '').trim() + if (state.captcha.enabled && !captchaToken) throw new Error('请先完成安全验证并填写验证结果') + let payload = await request('/login', { method: 'POST', body: JSON.stringify({ + email: form.get('email'), + password: form.get('password'), + turnstile_token: captchaToken || undefined, + tencent_captcha_ticket: state.captcha.provider === 'tencent' ? captchaToken || undefined : undefined, + tencent_captcha_randstr: state.captcha.provider === 'tencent' ? String(form.get('captcha_randstr') || '').trim() || undefined : undefined + }) }) + if (payload.requires_2fa) { + const code = window.prompt('请输入管理员 2FA 验证码') + if (!code) throw new Error('需要 2FA 验证码') + payload = await request('/login/2fa', { method: 'POST', body: JSON.stringify({ pending_token: payload.pending_token, totp_code: code }) }) + } + if (!payload.ok || !payload.csrf_token) throw new Error('登录响应无效') + state.csrf = payload.csrf_token; loginView.hidden = true; appView.hidden = false + const user = payload.user || {}; $('#operator').textContent = user.email || user.username || `管理员 #${user.id || '-'}` + await Promise.all([loadStatus(), loadOverview()]) + } catch (error) { showError(error.message, $('#login-error')) } finally { button.disabled = false } + } + + async function loadPlans() { + try { const payload = unwrap(await request('/api/plans')); state.plans = Array.isArray(payload) ? payload : []; $('#plan-count').textContent = formatNumber(state.plans.length); renderPlans(); return true } + catch (error) { showError(error.message); return false } + } + async function loadSubscriptions() { + try { + const params = new URLSearchParams(); const form = new FormData($('#subscription-filter')) + const userID = String(form.get('user_id') || '').trim(); const status = String(form.get('status') || '') + if (userID && !/^[1-9][0-9]*$/.test(userID)) throw new Error('用户 ID 必须是正整数') + let endpoint = '/api/subscriptions' + if (userID && !status) endpoint = `/api/users/${encodeURIComponent(userID)}/subscriptions` + if (endpoint === '/api/subscriptions') { if (userID) params.set('user_id', userID); if (status) params.set('status', status); params.set('page', String(state.subscriptionPage)); params.set('page_size', String(state.subscriptionPageSize)) } + const payload = unwrap(await request(`${endpoint}${params.toString() ? `?${params.toString()}` : ''}`)) || {}; state.subscriptions = payload + $('#subscription-count').textContent = formatNumber(Array.isArray(payload) ? payload.length : (payload.total ?? payload.items?.length ?? 0)); renderSubscriptions(payload); return true + } catch (error) { showError(error.message); return false } + } + async function loadHealth() { try { const response = await fetch(route('/healthz'), { credentials: 'same-origin', headers: { Accept: 'application/json' } }); return response.ok } catch { return false } } + async function loadOverview() { + clearError(); $('#sync-time').textContent = '同步中' + const results = await Promise.all([loadHealth(), loadPlans(), loadSubscriptions()]) + const healthy = results.every(Boolean); const degraded = results.some(Boolean) + setCoreStatus(healthy ? 'ok' : degraded ? 'degraded' : 'bad') + $('#sync-time').textContent = degraded ? `最近同步:${formatDate(new Date().toISOString())}` : '同步失败' + } + async function loadAudit() { try { const payload = await request('/api/audit'); renderAudit(payload.items || []) } catch (error) { showError(error.message) } } + async function loadStatus() { + try { const payload = await request('/api/status'); $('#credential-status').textContent = payload.credential_state === 'server_managed' ? '服务端托管(不回显)' : '不可用' } + catch { $('#credential-status').textContent = '不可用' } + } + async function loadCaptchaConfig() { + try { + const payload = await request('/api/captcha-config'); state.captcha = payload + const panel = $('#captcha-panel') + if (!payload.enabled) { panel.hidden = true; return } + panel.hidden = false; $('#captcha-label').textContent = `${payload.provider || '安全'}验证` + const descriptions = { geetest: 'Core 已启用 GeeTest。请在官方验证组件完成挑战后,将返回的 JSON 验证结果粘贴到此处。', turnstile: 'Core 已启用 Cloudflare Turnstile。请完成挑战后填写返回的验证结果。', tencent: 'Core 已启用腾讯验证码。请填写 ticket,并在下方填写 randstr。', aliyun: 'Core 已启用阿里云验证码。请填写 captchaVerifyParam。' } + $('#captcha-help').textContent = descriptions[payload.provider] || '请完成 Core 配置的验证码后填写验证结果。' + $('#captcha-randstr-row').hidden = payload.provider !== 'tencent' + } catch { $('#captcha-panel').hidden = true } + } + async function loadBalance(userID) { + if (!/^[1-9][0-9]*$/.test(userID)) throw new Error('用户 ID 必须是正整数') + const payload = unwrap(await request(`/api/users/${encodeURIComponent(userID)}`)) || {}; $('#user-balance').textContent = formatNumber(payload.balance) + } + async function bootstrap() { + try { + const payload = await request('/api/me'); state.csrf = payload.csrf_token || '' + if (!state.csrf || !payload.user) throw new Error('session unavailable') + loginView.hidden = true; appView.hidden = false + const user = payload.user; $('#operator').textContent = user.email || user.username || `管理员 #${user.id || '-'}` + await Promise.all([loadCaptchaConfig(), loadStatus(), loadOverview()]) + } catch { loginView.hidden = false; appView.hidden = true; await loadCaptchaConfig() } + } + function setCoreStatus(stateName) { const element = $('#core-status'); const labels = { ok: '已连接', degraded: '部分可用', bad: '不可用' }; element.textContent = labels[stateName] || '检查中'; element.className = `status ${stateName === 'ok' ? 'ok' : stateName === 'bad' ? 'bad' : ''}` } + function parseFeatures(features) { if (!features) return []; if (Array.isArray(features)) return features; if (typeof features !== 'string') return []; try { const parsed = JSON.parse(features); return Array.isArray(parsed) ? parsed : [] } catch { return features.split(/[,\n]/).map((item) => item.trim()).filter(Boolean) } } + function renderPlans() { + const container = $('#plans-list'); if (!state.plans.length) { container.innerHTML = '

暂无套餐数据

'; return } + container.innerHTML = state.plans.map((plan) => { + const groups = (plan.included_groups || []).map((group) => `${escapeHTML(group.name || group.id)}`).join('') || '未返回分组' + const features = parseFeatures(plan.features) + return `

${escapeHTML(plan.name || plan.product_name || `套餐 #${plan.id}`)}

${escapeHTML(plan.description || '')}

${plan.for_sale ? '可售' : '下架'}
${formatNumber(plan.price)} ${escapeHTML(plan.currency || '')}
有效期
${formatNumber(plan.validity_days)} ${escapeHTML(plan.validity_unit || '天')}
周期额度
${formatNumber(plan.cycle_quota_usd)}
总额度
${formatNumber(plan.total_quota_usd)}
实例上限
${formatNumber(plan.max_subscriptions_per_user)}
${groups}
${features.length ? `
    ${features.map((feature) => `
  • ${escapeHTML(feature)}
  • `).join('')}
` : ''}
` + }).join('') + } + function renderSubscriptions(payload) { + const container = $('#subscriptions-list'); const items = Array.isArray(payload) ? payload : (payload.items || []); const pagination = $('#subscription-pagination') + if (Array.isArray(payload) || !payload.pages) pagination.hidden = true + else { pagination.hidden = false; $('#subscription-page-info').textContent = `第 ${payload.page || state.subscriptionPage} / ${payload.pages} 页,共 ${formatNumber(payload.total)} 条`; $('#subscription-prev').disabled = (payload.page || state.subscriptionPage) <= 1; $('#subscription-next').disabled = (payload.page || state.subscriptionPage) >= payload.pages } + if (!items.length) { container.innerHTML = '

暂无订阅数据

'; return } + container.innerHTML = `${items.map((item) => ``).join('')}
订阅实例用户套餐状态有效期周期用量
#${escapeHTML(item.id)}${escapeHTML(item.user?.username || item.user?.email || item.user_id || '-')}${escapeHTML(item.plan_name || item.plan_id || '-')}${statusLabel(item.status)}${formatDate(item.starts_at)}
至 ${formatDate(item.expires_at)}
${formatNumber(item.cycle_usage_usd)} / ${formatNumber(item.cycle_quota_usd)}
` + container.querySelectorAll('.detail-button').forEach((button) => button.addEventListener('click', () => showDetail(button.dataset.id))) + } + async function showDetail(id) { try { const payload = unwrap(await request(`/api/subscriptions/${encodeURIComponent(id)}`)); $('#detail-content').textContent = JSON.stringify(payload, null, 2); $('#detail-dialog').showModal() } catch (error) { showError(error.message) } } + function renderAudit(items) { const container = $('#audit-list'); if (!items.length) { container.innerHTML = '

暂无操作记录

'; return }; container.innerHTML = `${items.slice().reverse().map((item) => ``).join('')}
时间动作结果用户 ID请求 ID
${formatDate(item.time)}${escapeHTML(item.action)}${statusLabel(item.result)}${escapeHTML(item.user_id ?? '-')}${escapeHTML(item.request_id || '-')}
` } + + loginForm.addEventListener('submit', login) + $('#logout').addEventListener('click', async () => { try { await request('/logout', { method: 'POST' }) } catch { /* session is cleared locally */ } state.csrf = ''; loginView.hidden = false; appView.hidden = true; loginForm.reset() }) + $('#refresh-all').addEventListener('click', loadOverview) + $('#subscription-filter').addEventListener('submit', (event) => { event.preventDefault(); state.subscriptionPage = 1; state.subscriptions = null; loadSubscriptions() }) + $('#subscription-prev').addEventListener('click', () => { if (state.subscriptionPage > 1) { state.subscriptionPage -= 1; loadSubscriptions() } }) + $('#subscription-next').addEventListener('click', () => { const pages = Number(state.subscriptions?.pages || 0); if (state.subscriptionPage < pages) { state.subscriptionPage += 1; loadSubscriptions() } }) + $('#balance-form').addEventListener('submit', async (event) => { event.preventDefault(); clearError(); const userID = String(new FormData(event.currentTarget).get('user_id') || '').trim(); try { await loadBalance(userID) } catch (error) { showError(error.message) } }) + $('#close-detail').addEventListener('click', () => $('#detail-dialog').close()) + document.querySelectorAll('.tab').forEach((button) => button.addEventListener('click', () => setView(button.dataset.view))) + document.querySelectorAll('.reload').forEach((button) => button.addEventListener('click', () => { if (button.dataset.target === 'plans') loadPlans(); if (button.dataset.target === 'subscriptions') { state.subscriptions = null; loadSubscriptions() }; if (button.dataset.target === 'audit') loadAudit() })) + void bootstrap() +})() diff --git a/plugins/subscription-admin/ui/embed.go b/plugins/subscription-admin/ui/embed.go new file mode 100644 index 0000000..4ba0108 --- /dev/null +++ b/plugins/subscription-admin/ui/embed.go @@ -0,0 +1,9 @@ +package ui + +import "embed" + +// FS contains the static administrator UI shipped with the business plugin. +// It is embedded so the service does not depend on a writable host directory. +// +//go:embed index.html app.js styles.css +var FS embed.FS diff --git a/plugins/subscription-admin/ui/index.html b/plugins/subscription-admin/ui/index.html new file mode 100644 index 0000000..334b3ab --- /dev/null +++ b/plugins/subscription-admin/ui/index.html @@ -0,0 +1,122 @@ + + + + + + + 订阅管理插件 + + + +
+
+
SUB2API EXTENSION
+

订阅管理

+

使用 Sub2API 管理员账号登录。普通用户没有访问权限。

+
+ + + + + +
+
+ + +
+ +

订阅详情

+

+    
+ + + diff --git a/plugins/subscription-admin/ui/styles.css b/plugins/subscription-admin/ui/styles.css new file mode 100644 index 0000000..1967807 --- /dev/null +++ b/plugins/subscription-admin/ui/styles.css @@ -0,0 +1,103 @@ +:root { color-scheme: light dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #17202a; background: #f4f7fa; font-synthesis: none; } +@media (prefers-color-scheme: dark) { :root { color: #edf2f7; background: #11161c; } } +* { box-sizing: border-box; } +[hidden] { display: none !important; } +body { margin: 0; min-width: 320px; } +button, input, select { font: inherit; } +button { cursor: pointer; } +button:disabled { cursor: wait; opacity: .6; } +.shell { width: min(1160px, calc(100% - 32px)); margin: 0 auto; padding: 32px 0 64px; } +.auth-panel { width: min(440px, 100%); margin: 10vh auto 0; padding: 32px; border: 1px solid #dce4eb; border-radius: 10px; background: #fff; box-shadow: 0 12px 36px rgb(20 38 56 / 8%); } +@media (prefers-color-scheme: dark) { .auth-panel, .metric, .plan-card, .notice, table, .detail-dialog { background: #18212b; border-color: #2b3947; } } +h1, h2, h3, p { margin: 0; } +h1 { margin-top: 6px; font-size: clamp(1.5rem, 3vw, 2.1rem); letter-spacing: 0; } +h2 { font-size: 1.2rem; } +h3 { font-size: 1rem; } +.eyebrow { color: #3977a9; font-size: .7rem; font-weight: 700; letter-spacing: .08em; } +.muted { color: #647384; font-size: .86rem; line-height: 1.5; } +@media (prefers-color-scheme: dark) { .muted { color: #a8b5c2; } } +.stack { display: grid; gap: 18px; } +form.stack { margin-top: 26px; } +label { display: grid; gap: 7px; color: #4f6070; font-size: .82rem; font-weight: 600; } +input, select, textarea { width: 100%; height: 36px; padding: 0 10px; border: 1px solid #cbd7e1; border-radius: 5px; color: inherit; background: transparent; outline: none; } +textarea { height: auto; min-height: 72px; padding: 8px 10px; resize: vertical; font: .78rem/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; } +input:focus, select:focus { border-color: #3977a9; box-shadow: 0 0 0 3px rgb(57 119 169 / 17%); } +button { min-height: 36px; border-radius: 5px; border: 1px solid transparent; padding: 0 14px; } +.primary { color: white; background: #3977a9; } +.primary:hover { background: #2e628e; } +.secondary { color: #2d536f; background: transparent; border-color: #b9c9d7; } +.secondary:hover { background: rgb(57 119 169 / 8%); } +.topbar, .section-heading, .card-heading, .top-actions, .filter-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.topbar { padding-bottom: 24px; border-bottom: 1px solid #d8e1e9; } +.top-actions { flex-wrap: wrap; justify-content: flex-end; } +.operator { max-width: 260px; overflow: hidden; color: #647384; font-size: .82rem; text-overflow: ellipsis; white-space: nowrap; } +.tabs { display: flex; gap: 6px; overflow-x: auto; padding: 18px 0; border-bottom: 1px solid #d8e1e9; } +.tab { color: #647384; background: transparent; border: 0; white-space: nowrap; } +.tab.active { color: #3977a9; box-shadow: inset 0 -2px #3977a9; } +.view { padding-top: 28px; } +.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; } +.metric { min-height: 122px; display: grid; align-content: space-between; gap: 8px; padding: 18px; border: 1px solid #dce4eb; border-radius: 8px; background: #fff; } +.metric > span { color: #647384; font-size: .82rem; } +.metric strong { font-size: 1.55rem; font-variant-numeric: tabular-nums; } +.metric small { color: #7b8996; font-size: .75rem; } +.status { color: #647384; } +.status.ok { color: #2f8b5c; } +.status.bad { color: #c14f4f; } +.notice { display: flex; gap: 12px; align-items: baseline; padding: 14px 16px; border: 1px solid #c6dce9; border-left: 3px solid #3977a9; border-radius: 6px; background: #fff; font-size: .86rem; } +.card-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-top: 18px; } +.plan-card { display: grid; gap: 15px; padding: 20px; border: 1px solid #dce4eb; border-radius: 8px; background: #fff; } +.plan-price { color: #3977a9; font-size: 1.7rem; font-weight: 700; font-variant-numeric: tabular-nums; } +.plan-price small { color: #647384; font-size: .8rem; font-weight: 500; } +.facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 0; } +.facts div { padding: 10px; border-radius: 5px; background: rgb(100 115 132 / 8%); } +.facts dt { color: #647384; font-size: .72rem; } +.facts dd { margin: 4px 0 0; font-size: .86rem; font-variant-numeric: tabular-nums; } +.tags { display: flex; flex-wrap: wrap; gap: 6px; } +.tag, .pill { display: inline-flex; align-items: center; min-height: 24px; padding: 0 8px; border-radius: 99px; font-size: .72rem; white-space: nowrap; } +.tag { color: #3977a9; background: rgb(57 119 169 / 11%); } +.pill { color: #647384; background: rgb(100 115 132 / 12%); } +.pill-active, .pill-success { color: #2f8b5c; background: rgb(47 139 92 / 13%); } +.pill-expired, .pill-revoked, .pill-failed, .pill-bad { color: #bd5050; background: rgb(189 80 80 / 13%); } +.feature-list { display: grid; gap: 5px; margin: 0; padding-left: 18px; color: #647384; font-size: .82rem; } +.filter-row { justify-content: flex-start; flex-wrap: wrap; margin-top: 18px; } +.filter-row label { width: min(220px, 100%); } +.balance-form { margin-top: 16px; } +.captcha-panel { display: grid; gap: 10px; margin: 2px 0 0; padding: 12px; border: 1px solid #dce4eb; border-radius: 6px; } +.captcha-panel legend { padding: 0 4px; color: #4f6070; font-size: .82rem; font-weight: 600; } +.pagination { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 12px; } +.pagination > div { display: flex; gap: 8px; } +.settings-list { display: grid; gap: 1px; overflow: hidden; border: 1px solid #dce4eb; border-radius: 8px; background: #dce4eb; } +.settings-list > div { display: flex; justify-content: space-between; gap: 18px; padding: 15px 16px; background: #fff; font-size: .84rem; } +.settings-list strong { font-weight: 600; text-align: right; } +@media (prefers-color-scheme: dark) { .settings-list { border-color: #2b3947; background: #2b3947; } .settings-list > div { background: #18212b; } } +.table-wrap { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; margin-top: 18px; border: 1px solid #dce4eb; border-radius: 8px; } +table { width: 100%; min-width: 760px; border-collapse: collapse; background: #fff; } +th, td { padding: 13px 14px; border-bottom: 1px solid #e2e8ee; text-align: left; vertical-align: top; font-size: .82rem; white-space: nowrap; } +th { color: #647384; font-size: .74rem; font-weight: 600; background: rgb(100 115 132 / 6%); } +tr:last-child td { border-bottom: 0; } +code { color: #3977a9; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .78rem; } +.link-button { min-height: 28px; padding: 0; color: #3977a9; background: transparent; border: 0; } +.empty { padding: 32px; color: #647384; text-align: center; } +.error { color: #b84d4d; font-size: .84rem; } +.detail-dialog { width: min(720px, calc(100% - 28px)); max-height: min(700px, calc(100dvh - 28px)); padding: 0; border: 1px solid #dce4eb; border-radius: 8px; color: inherit; background: #fff; } +.detail-dialog::backdrop { background: rgb(15 27 39 / 42%); } +.dialog-heading { display: flex; justify-content: space-between; align-items: center; padding: 16px 18px; border-bottom: 1px solid #dce4eb; } +.icon-button { width: 32px; min-height: 32px; padding: 0; color: #647384; background: transparent; border: 0; font-size: 1.3rem; } +pre { max-height: 580px; overflow: auto; margin: 0; padding: 18px; font: .76rem/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere; } +@media (max-width: 760px) { + .shell { width: min(100% - 20px, 600px); padding-top: 18px; } + .auth-panel { margin-top: 4vh; padding: 22px; } + .topbar, .section-heading { align-items: flex-start; flex-direction: column; } + .top-actions { width: 100%; justify-content: space-between; } + .metric-grid, .card-list { grid-template-columns: 1fr; } + .metric { min-height: 104px; } + .notice { align-items: flex-start; flex-direction: column; gap: 5px; } + .filter-row { align-items: stretch; flex-direction: column; } + .filter-row label { width: 100%; } + .filter-row button { width: 100%; } + .pagination { align-items: stretch; flex-direction: column; } + .pagination > div { width: 100%; } + .pagination button { flex: 1; } + .settings-list > div { align-items: flex-start; flex-direction: column; gap: 5px; } + .settings-list strong { text-align: left; } +}