feat: add controlled plugin marketplace lifecycle
This commit is contained in:
@@ -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 或其他服务密钥。
|
||||
|
||||
+8
-4
@@ -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 管理员菜单不会出现“订阅管理”。
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,7 +96,7 @@ POST /api/menu-items/apply
|
||||
## 6. 插件生命周期
|
||||
|
||||
```text
|
||||
discovered -> verified -> installed -> disabled -> starting -> healthy
|
||||
discovered -> verified -> staged/disabled -> starting -> healthy
|
||||
│ │
|
||||
│ └── error
|
||||
└── incompatible
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
|
||||
控制面负责:
|
||||
|
||||
- 展示已登记、已安装和可升级的插件包;
|
||||
- 展示插件市场、已入库、运行中和可升级的插件包;
|
||||
- 校验清单、签名、文件哈希和 Core 兼容性;
|
||||
- 安装、启用、停用、升级、回滚、卸载和配置;
|
||||
- 下载/上传入库、启用、停用、升级、回滚、删除和配置;
|
||||
- 显示运行状态、健康检查结果和操作审计;
|
||||
- 对插件声明的管理员菜单执行预览和应用。
|
||||
|
||||
@@ -26,13 +26,13 @@
|
||||
管理员登录 plugin-admin
|
||||
|
|
||||
v
|
||||
插件目录 -> 上传/选择业务插件包
|
||||
插件市场/本地上传 -> 选择业务插件包
|
||||
|
|
||||
v
|
||||
清单 + 签名 + 哈希 + Core 兼容性校验
|
||||
|
|
||||
v
|
||||
安装到独立 revision,初始为 disabled
|
||||
下载并校验后写入独立 revision,初始为 disabled(已入库、待启用)
|
||||
|
|
||||
v
|
||||
启用 -> 启动独立服务端口 -> healthz/readyz/版本检查
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 == "" {
|
||||
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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"source": "replace-with-your-controlled-catalog",
|
||||
"entries": []
|
||||
}
|
||||
@@ -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) => `<span class="badge badge-${String(state || '').replace(/[^a-z_]/g, '')}">${escapeHtml(state || 'unknown')}</span>`
|
||||
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)
|
||||
? `<span class="market-status">已入库 · ${escapeHtml(item.installed_status === 'installed' ? '运行中' : item.installed_status === 'stopped' ? '已停用' : item.installed_state === 'error' ? '启用失败' : item.installed_state === 'incompatible' ? 'Core 不兼容' : '待启用')}</span>`
|
||||
: '<span class="market-status market-status-available">可安装</span>'
|
||||
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 = '<div class="empty">暂无可用插件</div>'; 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 = `<div class="market-title"><div><h3>${escapeHtml(item.name || pluginId)}</h3><p class="muted mono">${escapeHtml(pluginId)} · v${escapeHtml(version)}</p></div>${marketplaceStatus(item)}</div><p class="market-description">${escapeHtml(item.description || '由受控插件目录提供的 Business Plugin')}</p><dl class="market-meta"><div><dt>发布者</dt><dd>${escapeHtml(item.publisher || item.publisher_key_id || '-')}</dd></div><div><dt>兼容性</dt><dd>${escapeHtml(item.compatibility && item.compatibility.status || item.compatibility_status || 'unknown')}</dd></div><div><dt>包 SHA-256</dt><dd class="mono">${escapeHtml(item.archive_sha256 || item.sha256 || '-')}</dd></div></dl><div class="market-actions"><button data-market-action="install" data-id="${escapeHtml(pluginId)}" data-version="${escapeHtml(version)}" class="button" ${installed ? 'disabled' : ''}>${marketButtonLabel}</button></div>`
|
||||
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 = '<div class="empty">还没有登记业务插件</div>'; return }
|
||||
for (const plugin of data.items) {
|
||||
const card = document.createElement('article'); card.className = 'plugin-card'
|
||||
card.innerHTML = `<div class="plugin-title"><div><h3>${escapeHtml(plugin.name)}</h3><p class="muted mono">${escapeHtml(plugin.plugin_id)} · v${escapeHtml(plugin.version)}</p></div>${badge(plugin.state)}</div><dl class="meta"><div><dt>能力</dt><dd>${(plugin.capabilities || []).map(escapeHtml).join(', ') || '未声明'}</dd></div><div><dt>活动 revision</dt><dd class="mono">${escapeHtml(plugin.active_revision || '-')}</dd></div><div><dt>Core 兼容性</dt><dd>${escapeHtml((plugin.compatibility || {}).status || 'unknown')}</dd></div></dl><p class="plugin-error">${escapeHtml(plugin.last_error || '')}</p><div class="card-actions"><button data-action="config" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary">配置</button><label class="file-button">升级<input data-action="upgrade-file" data-id="${escapeHtml(plugin.plugin_id)}" type="file" accept=".zip,.s2plugin,application/zip"></label><button data-action="enable" data-id="${escapeHtml(plugin.plugin_id)}" class="button" ${plugin.state === 'healthy' ? 'disabled' : ''}>启用</button><button data-action="disable" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary" ${plugin.state !== 'healthy' ? 'disabled' : ''}>停用</button><button data-action="rollback" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary">回滚</button><button data-action="menu-preview" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary">菜单预览</button><button data-action="menu-apply" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary">应用菜单</button><button data-action="uninstall" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-danger" ${plugin.state === 'healthy' ? 'disabled' : ''}>卸载</button></div>`
|
||||
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 = `<div class="plugin-title"><div><h3>${escapeHtml(plugin.name)}</h3><p class="muted mono">${escapeHtml(plugin.plugin_id)} · v${escapeHtml(plugin.version)}</p></div><div class="state-stack">${badge(plugin.state)}${installedLabel ? `<span class="pending-label">${installedLabel}</span>` : ''}</div></div><dl class="meta"><div><dt>能力</dt><dd>${(plugin.capabilities || []).map(escapeHtml).join(', ') || '未声明'}</dd></div><div><dt>活动 revision</dt><dd class="mono">${escapeHtml(plugin.active_revision || '-')}</dd></div><div><dt>Core 兼容性</dt><dd>${escapeHtml((plugin.compatibility || {}).status || 'unknown')}</dd></div></dl><p class="plugin-error">${escapeHtml(plugin.last_error || '')}</p><div class="card-actions"><button data-action="config" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary">配置</button><label class="file-button">升级<input data-action="upgrade-file" data-id="${escapeHtml(plugin.plugin_id)}" type="file" accept=".zip,.s2plugin,application/zip"></label><button data-action="enable" data-id="${escapeHtml(plugin.plugin_id)}" class="button" ${plugin.state === 'healthy' ? 'disabled' : ''}>启用</button><button data-action="disable" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary" ${plugin.state !== 'healthy' ? 'disabled' : ''}>停用</button><button data-action="rollback" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary">回滚</button><button data-action="menu-preview" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary">菜单预览</button><button data-action="menu-apply" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary">应用菜单</button><button data-action="uninstall" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-danger" ${plugin.state === 'healthy' ? 'disabled' : ''}>删除</button></div>`
|
||||
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 = `<span>${escapeHtml(item.action)}</span><span class="mono">${escapeHtml(item.plugin_id || '-')}</span><span>${escapeHtml(item.result)}</span><time>${escapeHtml(item.time)}</time>`; 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))
|
||||
})()
|
||||
|
||||
@@ -39,15 +39,33 @@
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<h2>已登记插件</h2>
|
||||
<p class="muted">安装包会先验签、校验哈希和 Core 兼容性,再进入停用状态。</p>
|
||||
<p class="muted">上传后只完成验签、哈希和 Core 兼容性校验并入库;点击启用后才会启动插件。</p>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<label class="file-button">安装插件<input id="package-file" type="file" accept=".zip,.s2plugin,application/zip"></label>
|
||||
<label class="file-button">上传并入库<input id="package-file" type="file" accept=".zip,.s2plugin,application/zip"></label>
|
||||
<button id="refresh" class="button button-secondary" type="button">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<p id="notice" class="notice" role="status"></p>
|
||||
<section class="marketplace-section" aria-labelledby="marketplace-heading">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2 id="marketplace-heading">插件市场</h2>
|
||||
<p class="muted">从受控目录查看可安装版本。安装后仅入库,需在下方手动启用。</p>
|
||||
</div>
|
||||
<button id="marketplace-refresh" class="button button-secondary" type="button">刷新市场</button>
|
||||
</div>
|
||||
<div id="marketplace" class="marketplace-list" aria-live="polite"></div>
|
||||
</section>
|
||||
<section aria-labelledby="installed-heading">
|
||||
<div class="section-heading installed-heading">
|
||||
<div>
|
||||
<h2 id="installed-heading">已入库插件</h2>
|
||||
<p class="muted">启用、停用、升级、回滚和卸载均需在插件卡片中手动操作。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="plugins" class="plugin-list"></div>
|
||||
</section>
|
||||
<section class="panel audit-panel">
|
||||
<div class="section-heading"><h2>操作审计</h2><button id="audit-refresh" class="button button-quiet" type="button">刷新</button></div>
|
||||
<div id="audit" class="audit-list"></div>
|
||||
|
||||
@@ -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%; } }
|
||||
|
||||
@@ -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)"
|
||||
|
||||
Reference in New Issue
Block a user