From 028b505c36b807e23775f46342af262dae234ad4 Mon Sep 17 00:00:00 2001 From: Qiufeng Date: Fri, 28 Aug 2026 00:51:34 +0800 Subject: [PATCH] feat: add controlled plugin marketplace lifecycle --- README.md | 10 +- deploy/README.md | 12 +- docs/BUSINESS_PLUGIN_ACCEPTANCE.md | 3 + docs/BUSINESS_PLUGIN_FRAMEWORK_V1.md | 17 +- docs/BUSINESS_PLUGIN_V1_IMPLEMENTATION.md | 8 +- docs/PLUGIN_FRAMEWORK_V1_RFC.md | 6 +- plugins/plugin-admin/.env.example | 4 + plugins/plugin-admin/README.md | 56 +- plugins/plugin-admin/main.go | 281 ++++++++- plugins/plugin-admin/main_test.go | 166 ++++++ plugins/plugin-admin/marketplace.go | 535 ++++++++++++++++++ .../marketplace/index.example.json | 5 + plugins/plugin-admin/ui/app.js | 42 +- plugins/plugin-admin/ui/index.html | 24 +- plugins/plugin-admin/ui/styles.css | 17 +- scripts/install-local.sh | 8 + 16 files changed, 1149 insertions(+), 45 deletions(-) create mode 100644 plugins/plugin-admin/marketplace.go create mode 100644 plugins/plugin-admin/marketplace/index.example.json diff --git a/README.md b/README.md index 91fed89..f59320c 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ HTTP API、管理员鉴权和 `custom_menu_items` 接入 Core;插件不导入 ## 目录 -- `plugins/plugin-admin`:通用插件管理控制面,负责清单、签名、安装、启用、 - 停用、升级、回滚、卸载、配置、健康检查、审计和菜单注入。 +- `plugins/plugin-admin`:通用插件管理控制面,负责清单、签名、插件市场、 + 下载入库、启用、停用、升级、回滚、删除、配置、健康检查、审计和菜单注入。 - `plugins/subscription-admin`:可选的订阅管理业务插件。它不是插件管理 控制面,只有安装、启用并应用菜单后才会出现。 - `docs/`:插件框架、清单、边界、架构、开发和验收契约。 @@ -50,9 +50,9 @@ Core 仓库,也不要让插件连接 Core PostgreSQL/Redis。 1. 启动 `plugin-admin` 和需要的业务插件,各自监听独立端口。 2. 使用 Core 管理员账号登录插件服务;普通账号被拒绝。 -3. 在 `plugin-admin` 上传并校验业务插件包,配置 loopback `service_url`。 -4. 启用插件并完成健康检查。 -5. 预览、确认并应用插件声明的管理员菜单。 +3. 在 `plugin-admin` 上传或从插件市场下载并校验业务插件包;包只进入“已入库,待启用”状态。 +4. 配置 loopback `service_url`,点击启用并完成健康检查后,插件才会启动。 +5. 预览、确认并应用插件声明的管理员菜单;停用后可删除插件。 Core 继续作为用户、余额、订阅、计费和用量账本的权威来源。插件浏览器端 不持有 Core JWT、Admin Key 或其他服务密钥。 diff --git a/deploy/README.md b/deploy/README.md index ab68a0f..7b7963b 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -60,15 +60,19 @@ sudo systemctl restart sub2api-plugin-admin sub2api-subscription-admin 至少确认 `CORE_BASE_URL`、插件端口、反向代理路径和 HTTPS Cookie 设置。生产 环境应启用 `PLUGIN_COOKIE_SECURE=true`,并为 plugin-admin 配置稳定的 -`PLUGIN_CONFIG_KEY` 和受信发布者公钥。 +`PLUGIN_CONFIG_KEY` 和受信发布者公钥。插件市场默认读取 +`/var/lib/sub2api-add/plugin-admin/marketplace/index.json`;可通过 +`PLUGIN_MARKETPLACE_INDEX` 指向受控本地索引或 HTTPS 索引,并用 +`PLUGIN_MARKETPLACE_ALLOWED_HOSTS` 限定远程索引和包的精确主机。 ## 菜单接入 1. 让反向代理把 `plugin-admin` 和业务插件分别转发到 `127.0.0.1:8090` 与 `127.0.0.1:8091`。 -2. 登录 plugin-admin,上传业务插件包并完成签名/哈希/兼容性校验。 -3. 配置业务插件的 loopback `service_url`。 -4. 启用、健康检查、菜单预览,然后应用菜单。 +2. 登录 plugin-admin,上传业务插件包,或在插件市场选择目录版本。 +3. 控制面完成签名、哈希和 Core 兼容性校验后,仅将包登记为“已入库,待启用”,不会启动进程。 +4. 配置业务插件的 loopback `service_url`,再点击启用;健康检查通过后才算安装完成。 +5. 预览、确认并应用插件声明的管理员菜单。 订阅插件是可选项;未安装或未应用菜单时,Core 管理员菜单不会出现“订阅管理”。 diff --git a/docs/BUSINESS_PLUGIN_ACCEPTANCE.md b/docs/BUSINESS_PLUGIN_ACCEPTANCE.md index dc2240b..bcbc10e 100644 --- a/docs/BUSINESS_PLUGIN_ACCEPTANCE.md +++ b/docs/BUSINESS_PLUGIN_ACCEPTANCE.md @@ -9,14 +9,17 @@ | 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-02A | 市场出站 | 索引/归档 HTTPS、精确主机 allowlist、DNS 私网拒绝、体积和重定向门禁 | `main_test.go:TestRemoteMarketplaceRequiresAllowlistAndExpiry`;`marketplace.go` 出站策略 | 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-01A | 市场入库 | 仅从受控索引下载,校验哈希/清单后保持 disabled,不启动进程 | `main_test.go:TestMarketplaceInstallStagesPackageWithoutStartingAndDeleteSupportsHTTPDelete` | passed | | LIFE-02 | 启停 | enable/disable/drain | 停用路径有 SIGTERM + drain 超时逻辑;进程组清理和外部服务不误停 | passed | | LIFE-03 | 升级 | 新 revision 健康后切换 | 失败升级保留 active;模式切换不继承端点;成功提交后才切换进程 | passed | | LIFE-04 | 卸载 | 先停用再卸载 | 先提交注册表删除,成功后再清理插件资源,不删除 Core 数据 | passed | +| LIFE-04A | 删除接口 | `DELETE /api/plugins/{id}` 与卸载语义一致 | 市场生命周期测试覆盖标准 DELETE | passed | | MENU-01 | 菜单 | preview/apply 自有 `custom_menu_items` | `main_test.go:TestMenuPreviewAndApplyPreserveOtherMenuItems` | passed | | MENU-02 | 嵌入 | iframe 和新窗口 | 本地控制面三视口登录/刷新;插件提供独立登录和新窗口入口 | passed | | API-01 | allowlist | 未声明路径和查询参数 | `allowedCorePath` 单元路径门禁;业务插件自身 allowlist 测试 | passed | diff --git a/docs/BUSINESS_PLUGIN_FRAMEWORK_V1.md b/docs/BUSINESS_PLUGIN_FRAMEWORK_V1.md index e76c3d5..be3ec8e 100644 --- a/docs/BUSINESS_PLUGIN_FRAMEWORK_V1.md +++ b/docs/BUSINESS_PLUGIN_FRAMEWORK_V1.md @@ -74,12 +74,16 @@ POST /logout GET /api/me GET /api/plugins GET /api/plugins/{id} +GET /api/marketplace +POST /api/marketplace/install +POST /api/plugins/install 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 +DELETE /api/plugins/{id} GET /api/plugins/{id}/config PUT /api/plugins/{id}/config GET /api/audit @@ -92,10 +96,10 @@ POST /api/menu-items/apply ## 6. 插件生命周期 ```text -discovered -> verified -> installed -> disabled -> starting -> healthy - │ │ - │ └── error - └── incompatible +discovered -> verified -> staged/disabled -> starting -> healthy + │ │ + │ └── error + └── incompatible healthy -> draining -> disabled healthy -> upgrading -> healthy @@ -104,8 +108,7 @@ healthy -> rollback_pending -> healthy - `discovered`:目录或清单被发现,尚未验签。 - `verified`:清单、签名、哈希和兼容性通过。 -- `installed`:版本包已安全写入 staging 并原子切换。 -- `disabled`:已安装但不接收业务请求,菜单默认隐藏。 +- `staged/disabled`:版本包已安全写入 staging 并原子切换,但仍是“已入库、待启用”;不接收业务请求,菜单默认隐藏。 - `starting`:进程启动、端口和健康检查进行中。 - `healthy`:健康端点、就绪端点和(若响应提供)版本检查通过。 - `draining`:菜单已撤销,托管进程正在执行有界终止;在途请求由插件进程或反向代理按部署约定处理。 @@ -132,7 +135,7 @@ ui/assets/... 控制面必须拒绝绝对路径、父目录跳转、重复条目、符号链接、未声明文件、超大文件和不匹配哈希。签名使用 Ed25519,签名覆盖 `manifest.json` 原始字节;清单中的 SHA-256 覆盖服务文件和 UI。发布者私钥不进入仓库、包、服务器或日志。 -安装使用临时目录和原子 rename;失败不得破坏 active revision。包来源默认是管理员上传或受控本地目录,V1 不自动从互联网下载任意包。 +安装使用临时目录和原子 rename;失败不得破坏 active revision。包来源默认是管理员上传或受控本地目录。插件市场只允许服务端读取 schema v1 索引,并对 HTTPS 主机做精确 allowlist;浏览器只能提交索引中的 plugin ID/version,不能指定任意下载 URL。市场下载完成后仍只进入 `staged/disabled`,必须由管理员显式启用并通过健康检查。 ## 8. Core API Adapter diff --git a/docs/BUSINESS_PLUGIN_V1_IMPLEMENTATION.md b/docs/BUSINESS_PLUGIN_V1_IMPLEMENTATION.md index 5f519d4..d170747 100644 --- a/docs/BUSINESS_PLUGIN_V1_IMPLEMENTATION.md +++ b/docs/BUSINESS_PLUGIN_V1_IMPLEMENTATION.md @@ -8,9 +8,9 @@ 控制面负责: -- 展示已登记、已安装和可升级的插件包; +- 展示插件市场、已入库、运行中和可升级的插件包; - 校验清单、签名、文件哈希和 Core 兼容性; -- 安装、启用、停用、升级、回滚、卸载和配置; +- 下载/上传入库、启用、停用、升级、回滚、删除和配置; - 显示运行状态、健康检查结果和操作审计; - 对插件声明的管理员菜单执行预览和应用。 @@ -26,13 +26,13 @@ 管理员登录 plugin-admin | v -插件目录 -> 上传/选择业务插件包 +插件市场/本地上传 -> 选择业务插件包 | v 清单 + 签名 + 哈希 + Core 兼容性校验 | v -安装到独立 revision,初始为 disabled +下载并校验后写入独立 revision,初始为 disabled(已入库、待启用) | v 启用 -> 启动独立服务端口 -> healthz/readyz/版本检查 diff --git a/docs/PLUGIN_FRAMEWORK_V1_RFC.md b/docs/PLUGIN_FRAMEWORK_V1_RFC.md index 5547502..5bfeffb 100644 --- a/docs/PLUGIN_FRAMEWORK_V1_RFC.md +++ b/docs/PLUGIN_FRAMEWORK_V1_RFC.md @@ -2,7 +2,7 @@ 状态:V1 通用插件控制面参考实现已落库;订阅业务插件只读适配与 Core Host Adapter/写操作仍为 Draft -本文规划一种不改动 Sub2API 核心代码、数据库和现有插件 ABI 的独立业务插件框架。当前 V1 控制面参考实现位于 `plugins/plugin-admin`,它负责插件清单、签名、安装、启停、升级、回滚、卸载、配置、审计和菜单注入;控制面本身不是订阅后台。每个业务插件(包括独立的 `plugins/subscription-admin`)作为可选的独立服务运行在自己的端口,通过控制面安装后再由部署层反向代理和现有“管理员可见自定义菜单”嵌入 Sub2API 页面。插件登录直接调用 Core 的现有鉴权,普通账号没有访问权限,也不复制 Core 用户表。 +本文规划一种不改动 Sub2API 核心代码、数据库和现有插件 ABI 的独立业务插件框架。当前 V1 控制面参考实现位于 `plugins/plugin-admin`,它负责插件清单、签名、插件市场、下载入库、启停、升级、回滚、卸载、配置、审计和菜单注入;控制面本身不是订阅后台。每个业务插件(包括独立的 `plugins/subscription-admin`)作为可选的独立服务运行在自己的端口,通过控制面安装后再由部署层反向代理和现有“管理员可见自定义菜单”嵌入 Sub2API 页面。插件登录直接调用 Core 的现有鉴权,普通账号没有访问权限,也不复制 Core 用户表。 V1 已实现范围以 `plugins/plugin-admin` 控制面和本文“当前实现范围”章节为准;本文中的订阅业务插件只是首个适配样例。Core Host Adapter、短时 Plugin Access Token、无感 SSO 和余额写操作仍是后续版本设计,不代表当前 Core 已提供这些接口。 @@ -241,6 +241,10 @@ Business Plugin V1 不直接套用现有 `manifest.schema.json`。建议新增 清单只声明能力和兼容范围,不授予数据库、路由或 secret 权限。V1 由部署脚本/反向代理保存清单、校验签名和允许的 Core API 路径;当前 Core 不会读取该清单,也没有业务插件注册表。插件发布包/容器镜像仍应签名,但签名验证属于部署门禁,不应写成现有 Core 能力。 +### 7.1.1 受控插件市场 + +`plugin-admin` 可从本地或受控 HTTPS `schema_version=1` 索引展示市场条目。市场条目至少声明 `plugin_id`、版本、Core baseline、发布者 key ID、归档 SHA-256 和归档地址;远程索引必须有 `expires_at`,索引与归档主机必须匹配精确 allowlist,禁止重定向、凭据、查询参数和私网 DNS 地址。浏览器只能提交索引中的 ID/版本,下载、验签、哈希和清单兼容性校验全部由控制面服务端执行。下载成功只进入 `disabled`(已入库、待启用),不会启动插件;管理员显式启用并通过 health/readiness 检查后才进入 `healthy`。市场安装不隐式升级已有 ID,升级仍走升级和回滚流程。 + ### 7.2 状态机 ```text diff --git a/plugins/plugin-admin/.env.example b/plugins/plugin-admin/.env.example index 4647fe8..f1456b3 100644 --- a/plugins/plugin-admin/.env.example +++ b/plugins/plugin-admin/.env.example @@ -12,3 +12,7 @@ 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={} +# The default catalog is a local file. For a remote catalog use an HTTPS URL +# and list its exact host (including port when non-standard) below. +PLUGIN_MARKETPLACE_INDEX=/var/lib/sub2api-add/plugin-admin/marketplace/index.json +PLUGIN_MARKETPLACE_ALLOWED_HOSTS= diff --git a/plugins/plugin-admin/README.md b/plugins/plugin-admin/README.md index 70e3373..a6717df 100644 --- a/plugins/plugin-admin/README.md +++ b/plugins/plugin-admin/README.md @@ -4,7 +4,8 @@ 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, +The control plane owns the installed-plugin registry, a constrained marketplace +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 @@ -41,10 +42,13 @@ session and encrypted plugin configuration. GET /healthz GET /readyz POST /login POST /login/2fa POST /logout +GET /api/marketplace POST /api/marketplace/install (JSON: plugin_id, version) GET /api/me GET /api/plugins GET /api/plugins/{id} +POST /api/plugins/install (multipart field: package) POST /api/plugins/{id}/install (multipart field: package) POST /api/plugins/{id}/upgrade (multipart field: package) POST /api/plugins/{id}/enable|disable|rollback|uninstall +DELETE /api/plugins/{id} (same delete operation as uninstall) GET|PUT /api/plugins/{id}/config POST /api/plugins/{id}/menu-preview|menu-apply POST /api/menu-items/preview|apply (JSON: {"plugin_id":"..."}) @@ -52,9 +56,53 @@ 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. +mutation returns an operation ID even when it completes synchronously. A local +upload or marketplace download only verifies and stages the package in the +registry (`disabled` / “已入库,待启用”); it never starts a process. The +administrator must configure the service and click **enable**. Only a +successful health and readiness check changes the plugin to `healthy` and +installs its runtime/menu. Failed installation and upgrade never replace the +active revision. Delete/uninstall is allowed only after disable and removes +plugin files, not Core data. + +## Marketplace catalog + +The marketplace is server-side only. The browser receives metadata and sends a +plugin ID/version; it never receives an archive URL and cannot request an +arbitrary download. Set `PLUGIN_MARKETPLACE_INDEX` to a local JSON file (the +default) or an HTTPS index URL. Remote indexes and archives are restricted to +the exact hosts in `PLUGIN_MARKETPLACE_ALLOWED_HOSTS`; HTTP is accepted only +for loopback sources in `PLUGIN_ENV=development`. Redirects, credentials, +queries, fragments, oversized responses, path escapes, and hash mismatches are +rejected. The package must still pass the normal manifest signature, file hash, +and Core compatibility checks. + +Catalog format (schema version 1): + +```json +{ + "schema_version": 1, + "source": "internal-release-catalog", + "entries": [ + { + "plugin_id": "example.plugin", + "name": "Example Plugin", + "version": "1.0.0", + "description": "Administrator extension", + "archive_url": "example.plugin-1.0.0.s2plugin", + "archive_sha256": "SHA256_OF_ARCHIVE", + "archive_size": 12345, + "publisher_key_id": "publisher-key-id", + "core_api_baseline": "sub2api-0.1.183", + "tested_core_versions": ["0.1.183"], + "capabilities": ["example.v1"] + } + ] +} +``` + +An entry is never an implicit upgrade. If the plugin ID is already registered, +use the existing upgrade flow, then enable it explicitly. ## Plugin package diff --git a/plugins/plugin-admin/main.go b/plugins/plugin-admin/main.go index f4e5bd5..2692828 100644 --- a/plugins/plugin-admin/main.go +++ b/plugins/plugin-admin/main.go @@ -229,6 +229,7 @@ type operation struct { RequestHash string `json:"request_hash,omitempty"` State string `json:"state"` Error string `json:"error,omitempty"` + Warning string `json:"warning,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -480,12 +481,14 @@ type app struct { pending map[string]pendingLogin processes map[string]*exec.Cmd pluginLocks map[string]*sync.Mutex + marketplaceConfig marketplaceService 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{}} + marketplace, _ := newMarketplaceService(filepath.Join(root, "marketplace", "index.json"), "", true) + 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{}, marketplaceConfig: marketplace} } func (a *app) lockPlugin(id string) func() { @@ -899,6 +902,160 @@ func (a *app) apiPlugins(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"items": items}) } +func (a *app) marketplace(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 + } + index, _, err := a.marketplaceConfig.loadIndex(r.Context()) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "marketplace is unavailable"}) + return + } + a.registry.mu.Lock() + installed := make(map[string]pluginRecord, len(a.registry.data.Plugins)) + for id, plugin := range a.registry.data.Plugins { + installed[id] = clonePluginRecord(plugin) + } + a.registry.mu.Unlock() + items := make([]map[string]any, 0, len(index.Entries)) + coreVersion := a.currentCoreVersion(r.Context()) + for _, entry := range index.Entries { + var plugin *pluginRecord + if value, ok := installed[entry.PluginID]; ok { + copy := value + plugin = © + } + item := publicMarketplaceEntry(entry, plugin) + compatibility := manifest.Manifest{CoreAPIBaseline: entry.CoreAPIBaseline, TestedCoreVersions: entry.TestedCoreVersions}.EvaluateCompatibility(coreVersion) + item["compatibility"] = compatibility + items = append(items, item) + } + writeJSON(w, http.StatusOK, map[string]any{ + "schema_version": index.SchemaVersion, + "source": index.Source, + "issued_at": index.IssuedAt, + "expires_at": index.ExpiresAt, + "items": items, + }) +} + +func publicMarketplaceEntry(entry marketplaceEntry, installed *pluginRecord) map[string]any { + item := map[string]any{ + "plugin_id": entry.PluginID, + "name": entry.Name, + "version": entry.Version, + "description": entry.Description, + "publisher_key_id": entry.PublisherKeyID, + "core_api_baseline": entry.CoreAPIBaseline, + "tested_core_versions": entry.TestedCoreVersions, + "capabilities": entry.Capabilities, + "archive_sha256": entry.ArchiveSHA256, + "archive_size": entry.ArchiveSize, + "release_notes": entry.ReleaseNotes, + "published_at": entry.PublishedAt, + "installed": installed != nil, + "installed_state": "", + "installed_status": "", + "installed_version": "", + "active_revision": "", + } + if installed != nil { + item["installed_state"] = installed.State + item["installed_status"] = installationStatus(*installed) + item["installed_version"] = installed.Manifest.Version + item["active_revision"] = installed.ActiveRevision + } + return item +} + +func (a *app) marketplaceInstall(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + raw, err := captureRequestBody(r, 32<<10) + if err != nil { + writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "request body exceeds size limit"}) + return + } + var input struct { + PluginID string `json:"plugin_id"` + Version string `json:"version"` + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&input); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "plugin_id and version are required"}) + return + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF || !marketplaceIDPattern.MatchString(strings.TrimSpace(input.PluginID)) || !marketplaceVersionPattern.MatchString(marketplaceEntryVersion(marketplaceEntry{Version: input.Version})) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "plugin_id and version are invalid"}) + return + } + input.PluginID = strings.TrimSpace(input.PluginID) + input.Version = marketplaceEntryVersion(marketplaceEntry{Version: input.Version}) + r.Body = io.NopCloser(bytes.NewReader(raw)) + op, s, ok := a.mutationAuthWithHash(w, r, "marketplace_install", input.PluginID, operationHashWithBody(r, raw)) + if !ok { + return + } + var installed pluginRecord + if index, origin, loadErr := a.marketplaceConfig.loadIndex(r.Context()); loadErr != nil { + err = loadErr + } else { + var entry *marketplaceEntry + for i := range index.Entries { + candidate := &index.Entries[i] + if candidate.PluginID == input.PluginID && marketplaceEntryVersion(*candidate) == input.Version { + entry = candidate + break + } + } + if entry == nil { + err = errors.New("plugin version is not available in the marketplace") + } else { + var archive []byte + archive, err = a.marketplaceConfig.archiveBytes(r.Context(), *entry, origin) + if err == nil { + var info packageInfo + info, err = a.inspectPackage(archive) + if err == nil { + if info.Manifest.PluginID != entry.PluginID || strings.TrimPrefix(info.Manifest.Version, "v") != input.Version { + err = errors.New("marketplace package metadata does not match the catalog") + } else if info.Manifest.Name != entry.Name || !sameCapabilities(info.Manifest.Capabilities, entry.Capabilities) { + err = errors.New("marketplace package metadata does not match the catalog") + } else if !sameCoreVersions(info.Manifest.TestedCoreVersions, entry.TestedCoreVersions) { + err = errors.New("marketplace package Core test metadata does not match the catalog") + } else if info.Manifest.Publisher.KeyID != entry.PublisherKeyID { + err = errors.New("marketplace package publisher does not match the catalog") + } else if strings.TrimPrefix(strings.TrimPrefix(info.Manifest.CoreAPIBaseline, "sub2api-"), "v") != strings.TrimPrefix(strings.TrimPrefix(entry.CoreAPIBaseline, "sub2api-"), "v") { + err = errors.New("marketplace package Core baseline does not match the catalog") + } else if compat := info.Manifest.EvaluateCompatibility(a.currentCoreVersion(r.Context())); !compat.Compatible { + err = errors.New("marketplace package is incompatible with the current Core") + } + } + if err == nil { + installed, err = a.installPackage(info) + } + } + } + } + if err == nil { + op.Revision = installed.ActiveRevision + } + finished, persistErr := a.finalizeOperation(op, err, auditEvent{Time: time.Now().UTC(), Action: "marketplace_install", PluginID: input.PluginID, ActorID: s.User["id"], RequestID: requestID(r)}) + if persistErr != nil { + writeOperationPersistenceError(w, finished) + return + } + a.operationResponse(w, finished) +} + 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"}) @@ -929,7 +1086,27 @@ func (a *app) publicPlugin(p pluginRecord) map[string]any { 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} + return map[string]any{"plugin_id": p.Manifest.PluginID, "name": p.Manifest.Name, "version": p.Manifest.Version, "capabilities": p.Manifest.SortedCapabilities(), "state": p.State, "installation_status": installationStatus(p), "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 installationStatus(p pluginRecord) string { + switch p.State { + case "healthy", "enabled": + return "installed" + case "disabled": + for _, revision := range p.Revisions { + if !revision.HealthyAt.IsZero() { + return "stopped" + } + } + return "staged" + case "incompatible": + return "staged" + case "starting", "draining", "upgrading", "rollback_pending": + return "transitioning" + default: + return "failed" + } } func (a *app) currentCoreVersion(ctx context.Context) string { @@ -986,7 +1163,7 @@ func (a *app) getPlugin(w http.ResponseWriter, r *http.Request) { } 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}) + writeJSON(w, http.StatusAccepted, map[string]any{"operation_id": op.ID, "state": op.State, "error": op.Error, "warning": op.Warning}) } func (a *app) finalizeOperation(op operation, operationErr error, event auditEvent) (operation, error) { @@ -1621,6 +1798,7 @@ func (a *app) withPlugin(w http.ResponseWriter, r *http.Request, kind string, fn } old := clonePluginRecord(p) var err error + var warning string if !found { err = errors.New("plugin not found") } else { @@ -1669,15 +1847,16 @@ func (a *app) withPlugin(w http.ResponseWriter, r *http.Request, kind string, fn } _ = 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) + // The registry commit is already complete. Report a warning while + // keeping the deletion completed; a later startup pass removes the + // private tombstones without requiring a second uninstall operation. + warning = sanitizeError(fmt.Errorf("plugin files remain pending cleanup: %w", cleanupErr)) } } else { _ = a.restoreOwnMenu(r.Context(), s.AccessToken, old, requestID(r)) } } + op.Warning = warning 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) @@ -1845,7 +2024,7 @@ func (a *app) rollback(w http.ResponseWriter, r *http.Request) { } func (a *app) uninstall(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { + if r.Method != http.MethodPost && r.Method != http.MethodDelete { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) return } @@ -2244,6 +2423,11 @@ func (a *app) promoteProcess(from, to string) { // healthy with no corresponding runtime. func (a *app) recoverPlugins() error { a.registry.mu.Lock() + // A prior delete may have committed the registry before the filesystem + // cleanup failed. Remove only tombstones whose original revision is no + // longer referenced; an interrupted delete still needs its tombstone to + // recover the registry record safely. + _ = a.cleanupUninstallTombstonesLocked() ids := make([]string, 0, len(a.registry.data.Plugins)) for id := range a.registry.data.Plugins { ids = append(ids, id) @@ -2318,6 +2502,66 @@ func (a *app) recoverPlugins() error { return nil } +func (a *app) cleanupUninstallTombstonesLocked() error { + live := map[string]struct{}{} + for _, plugin := range a.registry.data.Plugins { + for _, revision := range plugin.Revisions { + if revision.Path == "" { + continue + } + if absolute, err := filepath.Abs(revision.Path); err == nil { + live[filepath.Clean(absolute)] = struct{}{} + } + } + } + installedRoot := filepath.Join(a.root, "installed") + pluginDirs, err := os.ReadDir(installedRoot) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + var firstErr error + for _, pluginDir := range pluginDirs { + if !pluginDir.IsDir() { + continue + } + entries, readErr := os.ReadDir(filepath.Join(installedRoot, pluginDir.Name())) + if readErr != nil { + if firstErr == nil { + firstErr = readErr + } + continue + } + for _, entry := range entries { + if !entry.IsDir() || !strings.Contains(entry.Name(), ".uninstall-") { + continue + } + originalName := strings.SplitN(entry.Name(), ".uninstall-", 2)[0] + originalPath, pathErr := filepath.Abs(filepath.Join(installedRoot, pluginDir.Name(), originalName)) + if pathErr == nil { + if _, referenced := live[filepath.Clean(originalPath)]; referenced { + if _, statErr := os.Lstat(originalPath); errors.Is(statErr, os.ErrNotExist) { + if restoreErr := os.Rename(filepath.Join(installedRoot, pluginDir.Name(), entry.Name()), originalPath); restoreErr != nil && firstErr == nil { + firstErr = restoreErr + } + } else if statErr == nil { + if removeErr := os.RemoveAll(filepath.Join(installedRoot, pluginDir.Name(), entry.Name())); removeErr != nil && firstErr == nil { + firstErr = removeErr + } + } + continue + } + } + if removeErr := os.RemoveAll(filepath.Join(installedRoot, pluginDir.Name(), entry.Name())); removeErr != nil && firstErr == nil { + firstErr = removeErr + } + } + } + return firstErr +} + 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"}) @@ -2500,6 +2744,8 @@ func (a *app) routes() http.Handler { mux.HandleFunc("/logout", a.logout) mux.HandleFunc("/api/me", a.me) mux.HandleFunc("/api/audit", a.audit) + mux.HandleFunc("/api/marketplace", a.marketplace) + mux.HandleFunc("/api/marketplace/install", a.marketplaceInstall) 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) @@ -2512,7 +2758,11 @@ func (a *app) routes() http.Handler { return } if action == "" { - a.getPlugin(w, r) + if r.Method == http.MethodDelete { + a.uninstall(w, r) + } else { + a.getPlugin(w, r) + } return } switch action { @@ -2528,6 +2778,8 @@ func (a *app) routes() http.Handler { a.rollback(w, r) case "uninstall": a.uninstall(w, r) + case "delete": + a.uninstall(w, r) case "config": a.config(w, r) case "menu-preview": @@ -2682,6 +2934,17 @@ func main() { } a.frameAncestors = parseFrameAncestors(os.Getenv("PLUGIN_FRAME_ANCESTORS")) a.trustedPublishers = loadTrustedPublishers(os.Getenv("PLUGIN_TRUSTED_PUBLISHERS")) + marketplaceSource := strings.TrimSpace(os.Getenv("PLUGIN_MARKETPLACE_INDEX")) + if marketplaceSource == "" { + marketplaceSource = filepath.Join(registryDir, "marketplace", "index.json") + } + allowLoopbackMarketplace := environment == "development" && isLoopbackHost(host) + marketplace, marketplaceErr := newMarketplaceService(marketplaceSource, os.Getenv("PLUGIN_MARKETPLACE_ALLOWED_HOSTS"), allowLoopbackMarketplace) + if marketplaceErr != nil { + slog.Error("invalid marketplace configuration", "error", marketplaceErr) + os.Exit(2) + } + a.marketplaceConfig = marketplace if err := a.recoverPlugins(); err != nil { slog.Error("recover plugins", "error", err) os.Exit(2) diff --git a/plugins/plugin-admin/main_test.go b/plugins/plugin-admin/main_test.go index fb57f39..8061825 100644 --- a/plugins/plugin-admin/main_test.go +++ b/plugins/plugin-admin/main_test.go @@ -3,6 +3,7 @@ package main import ( "archive/zip" "bytes" + "context" "crypto/ed25519" "crypto/sha256" "encoding/base64" @@ -14,6 +15,7 @@ import ( "mime/multipart" "net/http" "net/http/httptest" + "net/url" "os" "os/exec" "path/filepath" @@ -254,6 +256,170 @@ func TestPackageInspectionAndAtomicInstall(t *testing.T) { } } +func TestMarketplaceInstallStagesPackageWithoutStartingAndDeleteSupportsHTTPDelete(t *testing.T) { + t.Setenv("CORE_VERSION", "0.1.183") + root := t.TempDir() + marketplaceDir := filepath.Join(root, "marketplace") + if err := os.MkdirAll(marketplaceDir, 0o700); err != nil { + t.Fatal(err) + } + archive := validPackage(t, "market.example") + archivePath := filepath.Join(marketplaceDir, "market.example-1.0.0.s2plugin") + if err := os.WriteFile(archivePath, archive, 0o600); err != nil { + t.Fatal(err) + } + index := marketplaceIndex{SchemaVersion: 1, Source: "test", Entries: []marketplaceEntry{{ + PluginID: "market.example", Name: "Example Plugin", Version: "1.0.0", + Description: "test package", ArchiveURL: filepath.Base(archivePath), ArchiveSHA256: sha256Hex(archive), ArchiveSize: int64(len(archive)), + PublisherKeyID: "dev", CoreAPIBaseline: "sub2api-0.1.183", TestedCoreVersions: []string{"0.1.183"}, Capabilities: []string{"example.v1"}, + }}} + indexRaw, err := json.Marshal(index) + if err != nil { + t.Fatal(err) + } + indexPath := filepath.Join(marketplaceDir, "index.json") + if err := os.WriteFile(indexPath, indexRaw, 0o600); 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": + _, _ = io.WriteString(w, `{"code":0,"data":{"custom_menu_items":[]}}`) + default: + _, _ = io.WriteString(w, `{"code":0,"data":{}}`) + } + })) + defer coreServer.Close() + reg, err := openRegistry(filepath.Join(root, "registry")) + if err != nil { + t.Fatal(err) + } + a := newApp(core, reg, root) + a.allowUnsigned = true + a.marketplaceConfig, err = newMarketplaceService(indexPath, "", true) + if err != nil { + t.Fatal(err) + } + cookie := adminSession(a) + listReq := httptest.NewRequest(http.MethodGet, "/api/marketplace", nil) + listReq.AddCookie(cookie) + listRec := httptest.NewRecorder() + a.routes().ServeHTTP(listRec, listReq) + if listRec.Code != http.StatusOK || !strings.Contains(listRec.Body.String(), "market.example") || strings.Contains(listRec.Body.String(), filepath.Base(archivePath)) { + t.Fatalf("marketplace listing was not metadata-only: %d %s", listRec.Code, listRec.Body.String()) + } + req := httptest.NewRequest(http.MethodPost, "/api/marketplace/install", strings.NewReader(`{"plugin_id":"market.example","version":"1.0.0"}`)) + req.AddCookie(cookie) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-CSRF-Token", "CSRF") + req.Header.Set("Idempotency-Key", "market-install-1") + rec := httptest.NewRecorder() + a.marketplaceInstall(rec, req) + if rec.Code != http.StatusAccepted || !strings.Contains(rec.Body.String(), `"completed"`) { + t.Fatalf("marketplace install failed: %d %s", rec.Code, rec.Body.String()) + } + reg.mu.Lock() + p, ok := reg.data.Plugins["market.example"] + reg.mu.Unlock() + if !ok || p.State != "disabled" || p.ActiveRevision == "" { + t.Fatalf("marketplace package was not staged as disabled: exists=%v record=%#v", ok, p) + } + a.mu.Lock() + processCount := len(a.processes) + a.mu.Unlock() + if processCount != 0 { + t.Fatalf("marketplace install unexpectedly started %d processes", processCount) + } + + deleteReq := httptest.NewRequest(http.MethodDelete, "/api/plugins/market.example", nil) + deleteReq.AddCookie(cookie) + deleteReq.Header.Set("X-CSRF-Token", "CSRF") + deleteReq.Header.Set("Idempotency-Key", "market-delete-1") + deleteRec := httptest.NewRecorder() + a.routes().ServeHTTP(deleteRec, deleteReq) + if deleteRec.Code != http.StatusAccepted { + t.Fatalf("DELETE plugin failed: %d %s", deleteRec.Code, deleteRec.Body.String()) + } + reg.mu.Lock() + _, exists := reg.data.Plugins["market.example"] + reg.mu.Unlock() + if exists { + t.Fatal("plugin remained in registry after DELETE") + } +} + +func TestMarketplaceRejectsExpiredIndexAndArchiveHashMismatch(t *testing.T) { + expired := marketplaceIndex{SchemaVersion: 1, ExpiresAt: time.Now().UTC().Add(-time.Minute).Format(time.RFC3339)} + if err := validateMarketplaceIndex(expired); err == nil { + t.Fatal("expected expired marketplace index rejection") + } + entry := marketplaceEntry{PluginID: "example.plugin", Version: "1.0.0", ArchiveSHA256: strings.Repeat("0", 64), ArchiveSize: 3} + if _, err := verifyMarketplaceArchive(entry, []byte("bad")); err == nil { + t.Fatal("expected marketplace archive hash mismatch") + } +} + +func TestRecoverRestoresInterruptedDeleteTombstone(t *testing.T) { + root := t.TempDir() + reg, err := openRegistry(filepath.Join(root, "registry")) + if err != nil { + t.Fatal(err) + } + original := filepath.Join(root, "installed", "recover.plugin", "rev-1") + if err := os.MkdirAll(filepath.Dir(original), 0o700); err != nil { + t.Fatal(err) + } + tombstone := original + ".uninstall-test" + if err := os.MkdirAll(tombstone, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tombstone, "marker"), []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + reg.data.Plugins["recover.plugin"] = pluginRecord{ + Manifest: manifest.Manifest{PluginID: "recover.plugin", Name: "Recover", Version: "1.0.0"}, + State: "disabled", + ActiveRevision: "rev-1", + Revisions: []revision{{ID: "rev-1", Path: original}}, + } + a := newApp(nil, reg, root) + if err := a.recoverPlugins(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(original, "marker")); err != nil { + t.Fatalf("interrupted uninstall was not restored: %v", err) + } + if _, err := os.Stat(tombstone); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("tombstone remained after restoration: %v", err) + } +} + +func TestRemoteMarketplaceRequiresAllowlistAndExpiry(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"schema_version":1,"source":"test","expires_at":"2099-01-01T00:00:00Z","entries":[]}`) + })) + defer server.Close() + if _, err := newMarketplaceService(server.URL, "", true); err == nil { + t.Fatal("expected remote marketplace allowlist requirement") + } + u, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + service, err := newMarketplaceService(server.URL, u.Host, true) + if err != nil { + t.Fatal(err) + } + index, _, err := service.loadIndex(context.Background()) + if err != nil || index.SchemaVersion != 1 { + t.Fatalf("remote marketplace index failed: %#v %v", index, err) + } +} + func TestProductionPackageRequiresTrustedSignature(t *testing.T) { t.Setenv("CORE_VERSION", "0.1.183") reg, _ := openRegistry(t.TempDir()) diff --git a/plugins/plugin-admin/marketplace.go b/plugins/plugin-admin/marketplace.go new file mode 100644 index 0000000..be0c8d7 --- /dev/null +++ b/plugins/plugin-admin/marketplace.go @@ -0,0 +1,535 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" +) + +const ( + maxMarketplaceIndexBytes = 2 << 20 + maxMarketplaceEntries = 256 +) + +var ( + marketplaceIDPattern = regexp.MustCompile(`^[a-z0-9]+(?:[._-][a-z0-9]+)+$`) + marketplaceVersionPattern = 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-]+)*)?$`) + marketplaceSHA256Pattern = regexp.MustCompile(`^[a-f0-9]{64}$`) +) + +// marketplaceEntry is deliberately metadata-only. The browser never receives +// the archive URL; downloads are performed by this server after catalog and +// transport policy validation. +type marketplaceEntry struct { + PluginID string `json:"plugin_id"` + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description,omitempty"` + ArchiveURL string `json:"archive_url"` + ArchiveSHA256 string `json:"archive_sha256"` + ArchiveSize int64 `json:"archive_size,omitempty"` + PublisherKeyID string `json:"publisher_key_id"` + CoreAPIBaseline string `json:"core_api_baseline"` + TestedCoreVersions []string `json:"tested_core_versions,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` + ReleaseNotes string `json:"release_notes,omitempty"` + PublishedAt string `json:"published_at,omitempty"` +} + +type marketplaceIndex struct { + SchemaVersion int `json:"schema_version"` + Source string `json:"source,omitempty"` + IssuedAt string `json:"issued_at,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + Entries []marketplaceEntry `json:"entries"` +} + +type marketplaceService struct { + localPath string + remoteURL *url.URL + allowedHosts map[string]struct{} + allowLoopback bool + client *http.Client +} + +type marketplaceOrigin struct { + localPath string + remoteURL *url.URL +} + +func (m marketplaceService) httpClient() *http.Client { + if m.client != nil { + return m.client + } + return &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{Proxy: nil, DialContext: marketplaceDialContext(m.allowLoopback)}, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + +func newMarketplaceService(source, allowedHosts string, allowLoopback bool) (marketplaceService, error) { + if strings.TrimSpace(source) == "" { + source = "./marketplace/index.json" + } + service := marketplaceService{ + allowedHosts: map[string]struct{}{}, + allowLoopback: allowLoopback, + client: &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{Proxy: nil, DialContext: marketplaceDialContext(allowLoopback)}, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + } + parsed, err := url.Parse(strings.TrimSpace(source)) + if err == nil && parsed.Scheme != "" { + if parsed.User != nil || parsed.Host == "" || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return marketplaceService{}, errors.New("PLUGIN_MARKETPLACE_INDEX must be an HTTPS URL without credentials, query or fragment") + } + if parsed.Scheme != "https" && !(allowLoopback && isLoopbackHost(parsed.Hostname())) { + return marketplaceService{}, errors.New("PLUGIN_MARKETPLACE_INDEX must use HTTPS unless it targets loopback in development") + } + service.remoteURL = parsed + for _, host := range strings.FieldsFunc(allowedHosts, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' || r == '\n' }) { + host = canonicalAllowedMarketplaceHost(host) + if host != "" { + service.allowedHosts[host] = struct{}{} + } + } + if len(service.allowedHosts) == 0 { + return marketplaceService{}, errors.New("PLUGIN_MARKETPLACE_ALLOWED_HOSTS is required for remote marketplace indexes") + } + if !service.hostAllowed(parsed) { + return marketplaceService{}, errors.New("PLUGIN_MARKETPLACE_INDEX host is not allowlisted") + } + return service, nil + } + if strings.Contains(source, "\x00") { + return marketplaceService{}, errors.New("PLUGIN_MARKETPLACE_INDEX contains an invalid path") + } + absolute, err := filepath.Abs(source) + if err != nil { + return marketplaceService{}, fmt.Errorf("resolve marketplace index: %w", err) + } + service.localPath = filepath.Clean(absolute) + return service, nil +} + +func (m marketplaceService) hostAllowed(u *url.URL) bool { + if u == nil { + return false + } + _, ok := m.allowedHosts[canonicalMarketplaceHost(u)] + return ok +} + +func canonicalMarketplaceHost(u *url.URL) string { + if u == nil { + return "" + } + host := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(u.Hostname()), ".")) + if host == "" { + return "" + } + port := u.Port() + if (u.Scheme == "https" && port == "443") || (u.Scheme == "http" && port == "80") { + port = "" + } + if port == "" { + return host + } + return net.JoinHostPort(host, port) +} + +func canonicalAllowedMarketplaceHost(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + parsed, err := url.Parse("//" + raw) + if err != nil || parsed.Host == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" { + return strings.ToLower(strings.TrimSuffix(raw, ".")) + } + host := strings.ToLower(strings.TrimSuffix(parsed.Hostname(), ".")) + port := parsed.Port() + if port == "80" || port == "443" { + port = "" + } + if port == "" { + return host + } + return net.JoinHostPort(host, port) +} + +func (m marketplaceService) loadIndex(ctx context.Context) (marketplaceIndex, marketplaceOrigin, error) { + var raw []byte + origin := marketplaceOrigin{localPath: m.localPath, remoteURL: m.remoteURL} + if m.remoteURL != nil { + if !m.hostAllowed(m.remoteURL) { + return marketplaceIndex{}, origin, errors.New("marketplace index host is not allowlisted") + } + if err := m.validateRemoteHost(ctx, m.remoteURL); err != nil { + return marketplaceIndex{}, origin, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.remoteURL.String(), nil) + if err != nil { + return marketplaceIndex{}, origin, err + } + res, err := m.httpClient().Do(req) + if err != nil { + return marketplaceIndex{}, origin, err + } + defer res.Body.Close() + if res.StatusCode < 200 || res.StatusCode >= 300 { + return marketplaceIndex{}, origin, fmt.Errorf("marketplace index returned HTTP %d", res.StatusCode) + } + if res.ContentLength > maxMarketplaceIndexBytes { + return marketplaceIndex{}, origin, errors.New("marketplace index exceeds size limit") + } + raw, err = io.ReadAll(io.LimitReader(res.Body, maxMarketplaceIndexBytes+1)) + if err != nil { + return marketplaceIndex{}, origin, err + } + if len(raw) > maxMarketplaceIndexBytes { + return marketplaceIndex{}, origin, errors.New("marketplace index exceeds size limit") + } + } else { + if m.localPath == "" { + return marketplaceIndex{}, origin, errors.New("marketplace index is not configured") + } + file, err := os.Open(m.localPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return marketplaceIndex{SchemaVersion: 1, Entries: []marketplaceEntry{}}, origin, nil + } + return marketplaceIndex{}, origin, err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return marketplaceIndex{}, origin, err + } + if info.Size() > maxMarketplaceIndexBytes { + return marketplaceIndex{}, origin, errors.New("marketplace index exceeds size limit") + } + raw, err = io.ReadAll(io.LimitReader(file, maxMarketplaceIndexBytes+1)) + if err != nil { + return marketplaceIndex{}, origin, err + } + if len(raw) > maxMarketplaceIndexBytes { + return marketplaceIndex{}, origin, errors.New("marketplace index exceeds size limit") + } + } + var index marketplaceIndex + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&index); err != nil { + return marketplaceIndex{}, origin, fmt.Errorf("decode marketplace index: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return marketplaceIndex{}, origin, errors.New("marketplace index must contain one JSON value") + } + return marketplaceIndex{}, origin, fmt.Errorf("decode marketplace index trailing data: %w", err) + } + if err := validateMarketplaceIndex(index); err != nil { + return marketplaceIndex{}, origin, err + } + if m.remoteURL != nil && strings.TrimSpace(index.ExpiresAt) == "" { + return marketplaceIndex{}, origin, errors.New("remote marketplace index must include expires_at") + } + return index, origin, nil +} + +func (m marketplaceService) validateRemoteHost(ctx context.Context, u *url.URL) error { + if u == nil || u.Hostname() == "" { + return errors.New("marketplace URL host is required") + } + lookupCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + ips, err := net.DefaultResolver.LookupIP(lookupCtx, "ip", u.Hostname()) + if err != nil { + return errors.New("marketplace host DNS lookup failed") + } + if len(ips) == 0 { + return errors.New("marketplace host has no address") + } + for _, ip := range ips { + if isForbiddenMarketplaceIP(ip) && !(m.allowLoopback && ip.IsLoopback()) { + return errors.New("marketplace host resolves to a private address") + } + } + return nil +} + +func isForbiddenMarketplaceIP(ip net.IP) bool { + return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsUnspecified() || ip.IsMulticast() +} + +func validateMarketplaceIndex(index marketplaceIndex) error { + if index.SchemaVersion != 1 { + return errors.New("marketplace schema_version must be 1") + } + if len(index.Entries) > maxMarketplaceEntries { + return errors.New("marketplace contains too many entries") + } + if value := strings.TrimSpace(index.IssuedAt); value != "" { + if issued, err := time.Parse(time.RFC3339, value); err != nil || issued.After(time.Now().UTC().Add(5*time.Minute)) { + return errors.New("marketplace issued_at is invalid") + } + } + if value := strings.TrimSpace(index.ExpiresAt); value != "" { + expires, err := time.Parse(time.RFC3339, value) + if err != nil { + return errors.New("marketplace expires_at is invalid") + } + if !expires.After(time.Now().UTC()) { + return errors.New("marketplace index has expired") + } + } + seen := map[string]struct{}{} + for _, entry := range index.Entries { + if !marketplaceIDPattern.MatchString(entry.PluginID) || len(entry.PluginID) > 160 { + return fmt.Errorf("invalid marketplace plugin_id: %s", entry.PluginID) + } + if strings.TrimSpace(entry.Name) == "" || len(entry.Name) > 160 { + return fmt.Errorf("invalid marketplace name for %s", entry.PluginID) + } + version := strings.TrimPrefix(strings.TrimSpace(entry.Version), "v") + if !marketplaceVersionPattern.MatchString(version) { + return fmt.Errorf("invalid marketplace version for %s", entry.PluginID) + } + if strings.TrimSpace(entry.ArchiveURL) == "" || len(entry.ArchiveURL) > 2048 || strings.ContainsAny(entry.ArchiveURL, "\x00\r\n") { + return fmt.Errorf("archive_url is required for %s", entry.PluginID) + } + if len(entry.Description) > 4096 || len(entry.ReleaseNotes) > 16384 { + return fmt.Errorf("marketplace description or release notes are too long for %s", entry.PluginID) + } + if !marketplaceSHA256Pattern.MatchString(strings.ToLower(strings.TrimSpace(entry.ArchiveSHA256))) { + return fmt.Errorf("invalid archive_sha256 for %s", entry.PluginID) + } + if entry.ArchiveSize < 0 || entry.ArchiveSize > maxPackageBytes { + return fmt.Errorf("invalid archive_size for %s", entry.PluginID) + } + if strings.TrimSpace(entry.PublisherKeyID) == "" || len(entry.PublisherKeyID) > 160 { + return fmt.Errorf("publisher_key_id is required for %s", entry.PluginID) + } + baseline := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(entry.CoreAPIBaseline), "sub2api-"), "v") + if !marketplaceVersionPattern.MatchString(baseline) { + return fmt.Errorf("invalid core_api_baseline for %s", entry.PluginID) + } + if len(entry.TestedCoreVersions) > 64 || len(entry.Capabilities) > 64 { + return fmt.Errorf("marketplace metadata contains too many values for %s", entry.PluginID) + } + if len(entry.TestedCoreVersions) == 0 { + return fmt.Errorf("tested_core_versions are required for %s", entry.PluginID) + } + for _, tested := range entry.TestedCoreVersions { + if !marketplaceVersionPattern.MatchString(strings.TrimPrefix(strings.TrimSpace(tested), "v")) { + return fmt.Errorf("invalid tested_core_versions for %s", entry.PluginID) + } + } + for _, capability := range entry.Capabilities { + if !marketplaceIDPattern.MatchString(capability) { + return fmt.Errorf("invalid capability for %s", entry.PluginID) + } + } + if len(entry.Capabilities) == 0 { + return fmt.Errorf("capabilities are required for %s", entry.PluginID) + } + key := entry.PluginID + "@" + version + if _, exists := seen[key]; exists { + return fmt.Errorf("duplicate marketplace entry: %s", key) + } + seen[key] = struct{}{} + } + return nil +} + +func marketplaceDialContext(allowLoopback bool) func(context.Context, string, string) (net.Conn, error) { + return func(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host) + if err != nil { + return nil, errors.New("marketplace host DNS lookup failed") + } + dialer := &net.Dialer{Timeout: 5 * time.Second} + var lastErr error + for _, ip := range ips { + if isForbiddenMarketplaceIP(ip) && !(allowLoopback && ip.IsLoopback()) { + lastErr = errors.New("marketplace host resolves to a private address") + continue + } + conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) + if dialErr == nil { + return conn, nil + } + lastErr = dialErr + } + if lastErr != nil { + return nil, lastErr + } + return nil, errors.New("marketplace host has no address") + } +} + +func sameCapabilities(left, right []string) bool { + left = append([]string(nil), left...) + right = append([]string(nil), right...) + sort.Strings(left) + sort.Strings(right) + if len(left) != len(right) { + return false + } + for i := range left { + if left[i] != right[i] { + return false + } + } + return true +} + +func sameCoreVersions(left, right []string) bool { + normalize := func(values []string) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + out = append(out, strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(value), "sub2api-"), "v")) + } + sort.Strings(out) + return out + } + return sameCapabilities(normalize(left), normalize(right)) +} + +func marketplaceEntryVersion(entry marketplaceEntry) string { + return strings.TrimPrefix(strings.TrimSpace(entry.Version), "v") +} + +func (m marketplaceService) archiveBytes(ctx context.Context, entry marketplaceEntry, origin marketplaceOrigin) ([]byte, error) { + if origin.remoteURL != nil { + relative, err := url.Parse(strings.TrimSpace(entry.ArchiveURL)) + if err != nil || relative.IsAbs() || relative.Host != "" || relative.User != nil || relative.RawQuery != "" || relative.Fragment != "" || relative.Path == "" || strings.HasPrefix(relative.Path, "/") || strings.Contains(relative.Path, "..") { + return nil, errors.New("marketplace archive URL must be a relative path without traversal") + } + archiveURL := origin.remoteURL.ResolveReference(relative) + if archiveURL.Scheme != "https" && !(m.allowLoopback && archiveURL.Scheme == "http" && isLoopbackHost(archiveURL.Hostname())) { + return nil, errors.New("marketplace archive URL must use HTTPS unless it targets loopback in development") + } + if !m.hostAllowed(archiveURL) { + return nil, errors.New("marketplace archive host is not allowlisted") + } + if err := m.validateRemoteHost(ctx, archiveURL); err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, archiveURL.String(), nil) + if err != nil { + return nil, err + } + res, err := m.httpClient().Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode < 200 || res.StatusCode >= 300 { + return nil, fmt.Errorf("marketplace archive returned HTTP %d", res.StatusCode) + } + if res.ContentLength > maxPackageBytes { + return nil, errors.New("marketplace archive exceeds package size limit") + } + data, err := io.ReadAll(io.LimitReader(res.Body, maxPackageBytes+1)) + if err != nil { + return nil, err + } + if len(data) > maxPackageBytes { + return nil, errors.New("marketplace archive exceeds package size limit") + } + return verifyMarketplaceArchive(entry, data) + } + archivePath, err := localMarketplaceArchivePath(origin.localPath, entry.ArchiveURL) + if err != nil { + return nil, err + } + file, err := os.Open(archivePath) + if err != nil { + return nil, err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return nil, err + } + if info.Size() > maxPackageBytes { + return nil, errors.New("marketplace archive exceeds package size limit") + } + data, err := io.ReadAll(io.LimitReader(file, maxPackageBytes+1)) + if err != nil { + return nil, err + } + if len(data) > maxPackageBytes { + return nil, errors.New("marketplace archive exceeds package size limit") + } + return verifyMarketplaceArchive(entry, data) +} + +func localMarketplaceArchivePath(indexPath, raw string) (string, error) { + if indexPath == "" { + return "", errors.New("local marketplace index is not configured") + } + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.IsAbs() || parsed.Host != "" || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", errors.New("local marketplace archive URL must be a relative path") + } + base := filepath.Dir(indexPath) + candidate := filepath.Clean(filepath.Join(base, filepath.FromSlash(parsed.Path))) + rel, err := filepath.Rel(base, candidate) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return "", errors.New("local marketplace archive path escapes the index directory") + } + baseResolved, err := filepath.EvalSymlinks(base) + if err != nil { + return "", err + } + resolved, err := filepath.EvalSymlinks(candidate) + if err != nil { + return "", err + } + rel, err = filepath.Rel(baseResolved, resolved) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return "", errors.New("local marketplace archive symlink escapes the index directory") + } + return resolved, nil +} + +func verifyMarketplaceArchive(entry marketplaceEntry, archive []byte) ([]byte, error) { + if entry.ArchiveSize > 0 && int64(len(archive)) != entry.ArchiveSize { + return nil, errors.New("marketplace archive size mismatch") + } + sum := sha256.Sum256(archive) + hash := hex.EncodeToString(sum[:]) + if !strings.EqualFold(hash, strings.TrimSpace(entry.ArchiveSHA256)) { + return nil, errors.New("marketplace archive hash mismatch") + } + return archive, nil +} diff --git a/plugins/plugin-admin/marketplace/index.example.json b/plugins/plugin-admin/marketplace/index.example.json new file mode 100644 index 0000000..2e56607 --- /dev/null +++ b/plugins/plugin-admin/marketplace/index.example.json @@ -0,0 +1,5 @@ +{ + "schema_version": 1, + "source": "replace-with-your-controlled-catalog", + "entries": [] +} diff --git a/plugins/plugin-admin/ui/app.js b/plugins/plugin-admin/ui/app.js index 4590d1a..030e8a1 100644 --- a/plugins/plugin-admin/ui/app.js +++ b/plugins/plugin-admin/ui/app.js @@ -5,6 +5,7 @@ let csrf = '' let pendingToken = '' let configPluginId = '' + let installedPlugins = new Map() const notice = (message, error = false) => { const el = $('#notice'); el.textContent = message || ''; el.className = error ? 'notice error' : 'notice' } @@ -41,25 +42,54 @@ 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 marketplaceInstalled = (item) => { + const installed = installedPlugins.get(String(item.plugin_id || '')) + return !!(item.installed || installed) + } + const marketplaceStatus = (item) => marketplaceInstalled(item) + ? `已入库 · ${escapeHtml(item.installed_status === 'installed' ? '运行中' : item.installed_status === 'stopped' ? '已停用' : item.installed_state === 'error' ? '启用失败' : item.installed_state === 'incompatible' ? 'Core 不兼容' : '待启用')}` + : '可安装' + const loadMarketplace = async () => { + const data = await api('/api/marketplace'); const root = $('#marketplace'); root.textContent = '' + const items = Array.isArray(data.items) ? data.items : [] + if (!items.length) { root.innerHTML = '
暂无可用插件
'; return } + for (const item of items) { + const pluginId = String(item.plugin_id || ''); const version = String(item.version || '') + const installed = marketplaceInstalled(item); const sameVersion = installed && String(item.installed_version || '') === version; const card = document.createElement('article'); card.className = 'market-card' + const marketButtonLabel = installed ? (sameVersion ? '已入库' : '请在已入库卡片中升级') : '下载并入库' + card.innerHTML = `

${escapeHtml(item.name || pluginId)}

${escapeHtml(pluginId)} · v${escapeHtml(version)}

${marketplaceStatus(item)}

${escapeHtml(item.description || '由受控插件目录提供的 Business Plugin')}

发布者
${escapeHtml(item.publisher || item.publisher_key_id || '-')}
兼容性
${escapeHtml(item.compatibility && item.compatibility.status || item.compatibility_status || 'unknown')}
包 SHA-256
${escapeHtml(item.archive_sha256 || item.sha256 || '-')}
` + root.appendChild(card) + } + } const loadPlugins = async () => { const data = await api('/api/plugins'); const root = $('#plugins'); root.textContent = '' + installedPlugins = new Map((data.items || []).map((plugin) => [String(plugin.plugin_id || ''), plugin])) 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 || '')}

` + const staged = plugin.installation_status === 'staged' || plugin.state === 'disabled' || plugin.state === 'incompatible' + const installedLabel = plugin.state === 'healthy' || plugin.state === 'enabled' ? '运行中' : plugin.installation_status === 'stopped' ? '已停用' : plugin.state === 'incompatible' ? 'Core 不兼容' : plugin.state === 'error' ? '启用失败' : staged ? '已入库 · 待启用' : '' + card.innerHTML = `

${escapeHtml(plugin.name)}

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

${badge(plugin.state)}${installedLabel ? `${installedLabel}` : ''}
能力
${(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 loadAll = async () => { try { await loadPlugins(); await Promise.all([loadMarketplace(), 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}${result.warning ? `;${result.warning}` : ''}`); 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 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()) + const installFromMarketplace = async (button) => { + const pluginId = button.dataset.id; const version = button.dataset.version + if (!pluginId || !version || button.disabled) return + button.disabled = true + try { const result = await api('/api/marketplace/install', { method: 'POST', headers: { 'Idempotency-Key': `marketplace-install-${pluginId}-${version}-${Date.now()}` }, body: JSON.stringify({ plugin_id: pluginId, version }) }); notice(`插件已入库,待手动启用:${result.operation_id || result.state}`); await loadAll() } catch (error) { button.disabled = false; notice(error.message, true) } + } + $('#login-form').addEventListener('submit', login); $('#twofa-form').addEventListener('submit', login2fa); $('#logout').addEventListener('click', logout); $('#refresh').addEventListener('click', loadAll); $('#marketplace-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) }) + $('#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 } if (action === 'uninstall' && !window.confirm('删除后将移除插件文件,Core 数据不会删除。继续吗?')) return; mutate(action, id) }) + $('#marketplace').addEventListener('click', (event) => { const button = event.target.closest('[data-market-action="install"]'); if (button) installFromMarketplace(button) }) 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 index afa9821..f2f9b13 100644 --- a/plugins/plugin-admin/ui/index.html +++ b/plugins/plugin-admin/ui/index.html @@ -39,15 +39,33 @@

已登记插件

-

安装包会先验签、校验哈希和 Core 兼容性,再进入停用状态。

+

上传后只完成验签、哈希和 Core 兼容性校验并入库;点击启用后才会启动插件。

- +

-
+
+
+
+

插件市场

+

从受控目录查看可安装版本。安装后仅入库,需在下方手动启用。

+
+ +
+
+
+
+
+
+

已入库插件

+

启用、停用、升级、回滚和卸载均需在插件卡片中手动操作。

+
+
+
+

操作审计

diff --git a/plugins/plugin-admin/ui/styles.css b/plugins/plugin-admin/ui/styles.css index 97f577e..3fdc0f4 100644 --- a/plugins/plugin-admin/ui/styles.css +++ b/plugins/plugin-admin/ui/styles.css @@ -17,11 +17,24 @@ input { width: 100%; height: 36px; padding: 0 10px; border: 1px solid #c7d0da; b .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; } +.marketplace-section { margin: 8px 0 24px; padding: 18px 0 22px; border-bottom: 1px solid #d9dee5; } +.marketplace-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 12px; } +.market-card { min-width: 0; padding: 16px; background: #fff; border: 1px solid #d9dee5; border-radius: 6px; } +.market-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } +.market-title h3 { overflow-wrap: anywhere; } +.market-description { min-height: 36px; margin: 12px 0; color: #475569; font-size: 13px; line-height: 1.5; } +.market-meta { display: grid; gap: 8px; margin: 0 0 14px; } +.market-meta div { min-width: 0; } +.market-meta dd { overflow-wrap: anywhere; } +.market-status { display: inline-flex; flex: 0 0 auto; padding: 4px 8px; border-radius: 999px; color: #146c43; background: #d9f6e5; font-size: 12px; white-space: nowrap; } +.market-status-available { color: #1e40af; background: #e5edff; } +.market-actions { display: flex; justify-content: flex-end; } +.market-actions .button { width: 100%; } .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; } +.plugin-card { background: #fff; border: 1px solid #d9dee5; border-radius: 6px; padding: 18px; } .plugin-title { align-items: flex-start; } .state-stack { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; } .pending-label { color: #5a6877; font-size: 11px; white-space: nowrap; } .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; } } +@media (max-width: 640px) { .shell { width: min(100% - 20px, 560px); padding-top: 20px; } .topbar, .toolbar, .marketplace-section > .section-heading { 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; } .marketplace-section .button { width: 100%; } } diff --git a/scripts/install-local.sh b/scripts/install-local.sh index 4df876a..3a13955 100755 --- a/scripts/install-local.sh +++ b/scripts/install-local.sh @@ -17,6 +17,10 @@ usage() { PLUGIN_ETC_DIR 环境文件目录,默认 /etc/sub2api-add PLUGIN_VAR_DIR 插件数据目录,默认 /var/lib/sub2api-add PLUGIN_SYSTEM_USER systemd 用户,默认 sub2api-plugin + PLUGIN_MARKETPLACE_INDEX + plugin-admin 市场索引(默认数据目录下的 marketplace/index.json) + PLUGIN_MARKETPLACE_ALLOWED_HOSTS + 远程市场索引/插件包允许的精确主机列表 EOF } @@ -85,6 +89,10 @@ install_one() { if [[ "$name" == plugin-admin ]]; then ensure_env_value "$env_file" PLUGIN_REGISTRY_DIR "$data_dir" + install -d -m 0700 "$data_dir/marketplace" + if [[ ! -f "$data_dir/marketplace/index.json" && -f "$source/marketplace/index.example.json" ]]; then + install -m 0600 "$source/marketplace/index.example.json" "$data_dir/marketplace/index.json" + fi if grep -q '^PLUGIN_CONFIG_KEY=generate-and-replace-with-a-random-32-byte-secret$' "$env_file" || ! grep -q '^PLUGIN_CONFIG_KEY=.' "$env_file"; then ensure_env_value "$env_file" PLUGIN_CONFIG_KEY "$(random_secret)"