feat: add controlled plugin marketplace lifecycle
Business Plugins CI / check (plugin-admin) (push) Successful in 1m35s
Business Plugins CI / check (subscription-admin) (push) Successful in 1m29s

This commit is contained in:
Qiufeng
2026-08-28 00:51:34 +08:00
parent dc1c799b98
commit 028b505c36
16 changed files with 1149 additions and 45 deletions
+5 -5
View File
@@ -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
View File
@@ -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 管理员菜单不会出现“订阅管理”。
+3
View File
@@ -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 |
+10 -7
View File
@@ -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
+4 -4
View File
@@ -8,9 +8,9 @@
控制面负责:
- 展示已登记、已安装和可升级的插件包;
- 展示插件市场、已入库、运行中和可升级的插件包;
- 校验清单、签名、文件哈希和 Core 兼容性;
- 安装、启用、停用、升级、回滚、卸载和配置;
- 下载/上传入库、启用、停用、升级、回滚、删除和配置;
- 显示运行状态、健康检查结果和操作审计;
- 对插件声明的管理员菜单执行预览和应用。
@@ -26,13 +26,13 @@
管理员登录 plugin-admin
|
v
插件目录 -> 上传/选择业务插件包
插件市场/本地上传 -> 选择业务插件包
|
v
清单 + 签名 + 哈希 + Core 兼容性校验
|
v
安装到独立 revision,初始为 disabled
下载并校验后写入独立 revision,初始为 disabled(已入库、待启用)
|
v
启用 -> 启动独立服务端口 -> healthz/readyz/版本检查
+5 -1
View File
@@ -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
+4
View File
@@ -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=
+52 -4
View File
@@ -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
+272 -9
View File
@@ -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 = &copy
}
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)
+166
View File
@@ -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())
+535
View File
@@ -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": []
}
+36 -6
View File
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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))
})()
+21 -3
View File
@@ -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>
<div id="plugins" class="plugin-list"></div>
<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>
+15 -2
View File
@@ -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%; } }
+8
View File
@@ -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)"