chore: initialize standalone business plugin repository
Business Plugins CI / check (plugin-admin) (push) Successful in 3m13s
Business Plugins CI / check (subscription-admin) (push) Successful in 1m41s

This commit is contained in:
Qiufeng
2026-08-27 23:36:08 +08:00
commit 5feae3ad41
59 changed files with 8950 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
CORE_BASE_URL=http://127.0.0.1:8080
PLUGIN_ENV=production
PLUGIN_HOST=127.0.0.1
PLUGIN_PORT=8090
PLUGIN_REGISTRY_DIR=/var/lib/sub2api/plugin-admin
PLUGIN_PUBLIC_BASE_PATH=/extensions/qiu.plugin-admin
PLUGIN_COOKIE_PATH=/extensions/qiu.plugin-admin/
PLUGIN_COOKIE_SECURE=false
PLUGIN_COOKIE_SAMESITE=lax
PLUGIN_FRAME_ANCESTORS='self'
PLUGIN_ALLOW_UNSIGNED=false
PLUGIN_CONFIG_KEY=generate-and-replace-with-a-random-32-byte-secret
# JSON object: {"publisher-key-id":"BASE64_ED25519_PUBLIC_KEY"}
PLUGIN_TRUSTED_PUBLISHERS={}
+15
View File
@@ -0,0 +1,15 @@
.PHONY: test build check browser-check
test:
go test ./... -count=1
build:
mkdir -p bin
CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o bin/plugin-admin .
check: test
node --check ui/app.js
git diff --check
browser-check:
./test/run-browser-check.sh
+76
View File
@@ -0,0 +1,76 @@
# Sub2API Business Plugin Control Plane V1
This directory contains the independent administrator-only control plane for
Business Plugins. It is deliberately separate from Sub2API Core and from the
existing `.s2plugin` OpenAI OAuth transport runtime.
The control plane owns the plugin catalog, signed package verification,
revision directories, lifecycle state, encrypted configuration metadata,
menu preview/apply, and its own audit log. Core remains authoritative for
users, administrator roles, balances, subscriptions, billing, and audit
records. The service never connects to Core PostgreSQL/Redis and never sends a
Core JWT or Admin Key to the browser.
## Run locally
```sh
CORE_BASE_URL=http://127.0.0.1:8080 \
PLUGIN_HOST=127.0.0.1 PLUGIN_PORT=8090 \
PLUGIN_REGISTRY_DIR=./data \
PLUGIN_ENV=development \
PLUGIN_ALLOW_UNSIGNED=true \
PLUGIN_CONFIG_KEY=local-development-secret-at-least-32-chars \
go run .
```
`PLUGIN_ALLOW_UNSIGNED=true` is a development-only switch. Production
packages must contain `signature.json`, use Ed25519, and match a trusted key
from `PLUGIN_TRUSTED_PUBLISHERS` (a JSON object of key ID to base64 public
key). `PLUGIN_CONFIG_KEY` is required in every environment; use a randomly
generated secret in production and keep it stable across restarts so encrypted
plugin configuration remains decryptable.
Open `/admin/` directly or expose the service through the reverse proxy in
`deploy/`. The first login is the existing Core administrator login; no plugin
user table is created. The control plane stores only a short-lived server-side
session and encrypted plugin configuration.
## Control-plane endpoints
```text
GET /healthz
GET /readyz
POST /login POST /login/2fa POST /logout
GET /api/me GET /api/plugins GET /api/plugins/{id}
POST /api/plugins/{id}/install (multipart field: package)
POST /api/plugins/{id}/upgrade (multipart field: package)
POST /api/plugins/{id}/enable|disable|rollback|uninstall
GET|PUT /api/plugins/{id}/config
POST /api/plugins/{id}/menu-preview|menu-apply
POST /api/menu-items/preview|apply (JSON: {"plugin_id":"..."})
GET /api/audit
```
Every mutation requires the plugin CSRF token and an `Idempotency-Key`. A
mutation returns an operation ID even when it completes synchronously. Failed
installation and upgrade never replace the active revision. Uninstall is
allowed only after disable and removes plugin files, not Core data.
## Plugin package
Packages are ZIP files with `manifest.json`, optional detached
`signature.json`, and declared `ui/` files. A package may also include
`service/` files when the control plane owns the plugin process; external
service packages omit `backend.command` and require a configured loopback
`service_url` before enabling. SHA-256 hashes in the manifest cover every
declared file. Absolute paths, traversal, duplicate entries, symlinks,
undeclared hashes, oversized files, unknown manifest fields, and untrusted
publishers are rejected before staging. Installation uses a per-plugin
revision directory and an atomic registry JSON update.
## Deliberate V1 limits
The control plane does not register Core routes, change Core migrations, run
arbitrary proxy URLs, provide transparent iframe SSO, or move billing and
subscription hot-path logic out of Core. A subscription manager remains a
separate business plugin that consumes its own typed Core API adapter.
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env sh
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
mkdir -p "$ROOT/bin"
CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o "$ROOT/bin/plugin-admin" "$ROOT"
@@ -0,0 +1,33 @@
{
"schema_version": 1,
"plugin_id": "qiu.plugin-admin",
"name": "Business Plugin Control Plane",
"version": "1.0.0",
"core_api_baseline": "sub2api-0.1.183",
"tested_core_versions": ["0.1.183"],
"capabilities": ["plugin.admin.v1"],
"backend": {
"health_path": "/healthz",
"readiness_path": "/readyz",
"listen_env": "PLUGIN_PORT"
},
"ui": {
"entrypoint": "ui/index.html",
"menu": {
"id": "qiu.plugin-admin",
"label": "插件管理",
"visibility": "admin",
"sort_order": 190
}
},
"publisher": { "key_id": "qiu-plugin-admin-dev" },
"core_api_allowlist": [
"POST /api/v1/auth/login",
"POST /api/v1/auth/login/2fa",
"POST /api/v1/auth/refresh",
"POST /api/v1/auth/logout",
"GET /api/v1/auth/me",
"GET /api/v1/settings/public",
"GET /api/v1/admin/settings"
]
}
@@ -0,0 +1,8 @@
CORE_ORIGIN {
handle_path /extensions/qiu.plugin-admin/* {
reverse_proxy 127.0.0.1:8090
}
}
# Set PLUGIN_PUBLIC_BASE_PATH=/extensions/qiu.plugin-admin and
# PLUGIN_COOKIE_PATH=/extensions/qiu.plugin-admin/.
@@ -0,0 +1,7 @@
{
"id": "qiu.plugin-admin",
"label": "插件管理",
"url": "https://CORE_ORIGIN/extensions/qiu.plugin-admin/",
"visibility": "admin",
"sort_order": 190
}
@@ -0,0 +1,13 @@
location /extensions/qiu.plugin-admin/ {
proxy_pass http://127.0.0.1:8090/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
}
# Set PLUGIN_PUBLIC_BASE_PATH and PLUGIN_COOKIE_PATH to this same path.
# Keep the service bound to loopback and expose it only through HTTPS.
@@ -0,0 +1,22 @@
[Unit]
Description=Sub2API Business Plugin Control Plane
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=sub2api-plugin
Group=sub2api-plugin
WorkingDirectory=/opt/sub2api/plugin-admin
EnvironmentFile=/etc/sub2api/plugin-admin.env
ExecStart=/opt/sub2api/plugin-admin/bin/plugin-admin
Restart=on-failure
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/sub2api/plugin-admin
[Install]
WantedBy=multi-user.target
+3
View File
@@ -0,0 +1,3 @@
module git.awaioi.com/awaioi/sub2api-add/plugins/plugin-admin
go 1.23
@@ -0,0 +1,414 @@
// Package manifest validates the standalone Business Plugin V1 manifest.
package manifest
import (
"bytes"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"os"
"regexp"
"sort"
"strings"
)
var (
pluginIDPattern = regexp.MustCompile(`^[a-z0-9]+(?:[._-][a-z0-9]+)+$`)
versionPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$`)
methodPattern = regexp.MustCompile(`^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)$`)
pathParam = regexp.MustCompile(`^\{[a-zA-Z][a-zA-Z0-9_-]*\}$`)
sha256Pattern = regexp.MustCompile(`^[a-f0-9]{64}$`)
)
// RequiredAllowlist contains the Core endpoints needed by every plugin BFF.
// Domain-specific read/write endpoints must be appended by each plugin.
var requiredAllowlist = []string{
"POST /api/v1/auth/login",
"POST /api/v1/auth/login/2fa",
"POST /api/v1/auth/refresh",
"POST /api/v1/auth/logout",
"GET /api/v1/auth/me",
"GET /api/v1/settings/public",
}
type Manifest struct {
SchemaVersion int `json:"schema_version"`
PluginID string `json:"plugin_id"`
Name string `json:"name"`
Version string `json:"version"`
CoreAPIBaseline string `json:"core_api_baseline"`
Capabilities []string `json:"capabilities"`
TestedCoreVersions []string `json:"tested_core_versions"`
Backend Backend `json:"backend"`
UI UI `json:"ui"`
Publisher Publisher `json:"publisher"`
CoreAPIAllowlist []string `json:"core_api_allowlist"`
Files map[string]string `json:"files,omitempty"`
}
type Backend struct {
HealthPath string `json:"health_path"`
ReadinessPath string `json:"readiness_path"`
ListenEnv string `json:"listen_env"`
Command string `json:"command,omitempty"`
}
type UI struct {
Entrypoint string `json:"entrypoint"`
Menu Menu `json:"menu"`
}
type Menu struct {
ID string `json:"id"`
Label string `json:"label"`
Visibility string `json:"visibility"`
SortOrder int `json:"sort_order"`
URL string `json:"url,omitempty"`
}
type Publisher struct {
KeyID string `json:"key_id"`
}
type Signature struct {
Algorithm string `json:"algorithm"`
KeyID string `json:"key_id"`
Signature string `json:"signature"`
}
// Compatibility is the control-plane decision for the current Core version.
type Compatibility struct {
Compatible bool `json:"compatible"`
Tested bool `json:"tested"`
Status string `json:"status"`
Message string `json:"message"`
}
func Load(path string) (Manifest, []byte, error) {
raw, err := os.ReadFile(path)
if err != nil {
return Manifest{}, nil, err
}
var m Manifest
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
if err := rejectDuplicateJSONKeys(raw); err != nil {
return Manifest{}, nil, err
}
if err := dec.Decode(&m); err != nil {
return Manifest{}, nil, fmt.Errorf("decode manifest: %w", err)
}
var trailing any
if err := dec.Decode(&trailing); err != io.EOF {
if err == nil {
return Manifest{}, nil, errors.New("manifest contains trailing JSON")
}
return Manifest{}, nil, fmt.Errorf("decode manifest trailing data: %w", err)
}
if err := Validate(m); err != nil {
return Manifest{}, nil, err
}
return m, raw, nil
}
func Validate(m Manifest) error {
if m.SchemaVersion != 1 {
return errors.New("schema_version must be 1")
}
if !pluginIDPattern.MatchString(m.PluginID) || len(m.PluginID) > 160 {
return errors.New("invalid plugin_id")
}
if strings.TrimSpace(m.Name) == "" || len(m.Name) > 160 {
return errors.New("name is required and must be at most 160 characters")
}
if !versionPattern.MatchString(strings.TrimPrefix(m.Version, "v")) {
return errors.New("invalid plugin version")
}
baseline := strings.TrimPrefix(strings.TrimPrefix(m.CoreAPIBaseline, "sub2api-"), "v")
if !versionPattern.MatchString(baseline) {
return errors.New("invalid core_api_baseline")
}
if len(m.Capabilities) == 0 {
return errors.New("capabilities must contain at least one capability")
}
seenCaps := map[string]struct{}{}
for _, capability := range m.Capabilities {
if !pluginIDPattern.MatchString(capability) || len(capability) > 160 {
return fmt.Errorf("invalid capability: %s", capability)
}
if _, ok := seenCaps[capability]; ok {
return fmt.Errorf("duplicate capability: %s", capability)
}
seenCaps[capability] = struct{}{}
}
for _, version := range m.TestedCoreVersions {
if !versionPattern.MatchString(strings.TrimPrefix(version, "v")) {
return fmt.Errorf("invalid tested_core_versions entry: %s", version)
}
}
if err := validateEndpointPath(m.Backend.HealthPath); err != nil {
return fmt.Errorf("backend.health_path: %w", err)
}
if err := validateEndpointPath(m.Backend.ReadinessPath); err != nil {
return fmt.Errorf("backend.readiness_path: %w", err)
}
if m.Backend.Command != "" {
if err := validateRelativePath(m.Backend.Command); err != nil || !strings.HasPrefix(strings.ReplaceAll(m.Backend.Command, "\\", "/"), "service/") {
return errors.New("backend.command must be a safe path under service/")
}
}
if strings.TrimSpace(m.Backend.ListenEnv) == "" || !regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`).MatchString(m.Backend.ListenEnv) {
return errors.New("backend.listen_env is invalid")
}
if err := validateUIEntrypoint(m.UI.Entrypoint); err != nil {
return fmt.Errorf("ui.entrypoint: %w", err)
}
if m.UI.Menu.ID != m.PluginID || strings.TrimSpace(m.UI.Menu.Label) == "" || len(m.UI.Menu.Label) > 160 || m.UI.Menu.Visibility != "admin" || m.UI.Menu.SortOrder < 0 {
return errors.New("ui.menu must bind to plugin_id, use admin visibility, and have a valid label/order")
}
if m.UI.Menu.URL != "" {
u, err := url.Parse(strings.TrimSpace(m.UI.Menu.URL))
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return errors.New("ui.menu.url must be an absolute http(s) URL without credentials or query")
}
}
if strings.TrimSpace(m.Publisher.KeyID) == "" || len(m.Publisher.KeyID) > 160 {
return errors.New("publisher.key_id is required")
}
if len(m.CoreAPIAllowlist) < len(requiredAllowlist) {
return fmt.Errorf("core_api_allowlist must contain at least %d entries", len(requiredAllowlist))
}
seen := map[string]struct{}{}
for _, entry := range m.CoreAPIAllowlist {
if err := validateAllowlistEntry(entry); err != nil {
return err
}
if _, ok := seen[entry]; ok {
return fmt.Errorf("duplicate allowlist entry: %s", entry)
}
seen[entry] = struct{}{}
}
for _, required := range requiredAllowlist {
if _, ok := seen[required]; !ok {
return fmt.Errorf("missing required allowlist entry: %s", required)
}
}
for path, hash := range m.Files {
if err := validateRelativePath(path); err != nil || !sha256Pattern.MatchString(hash) {
return fmt.Errorf("invalid file hash declaration: %s", path)
}
}
if len(m.Files) > 0 && strings.HasPrefix(m.UI.Entrypoint, "ui/") {
if _, ok := m.Files[m.UI.Entrypoint]; !ok {
return errors.New("ui.entrypoint is missing from files")
}
}
return nil
}
func validatePluginPath(value string) error {
p := strings.ReplaceAll(strings.TrimSpace(value), "\\", "/")
if p == "" || strings.HasPrefix(p, "/") || strings.Contains(p, "\x00") || strings.Contains(p, "..") || strings.Contains(p, "*") || strings.Contains(p, "?") || strings.Contains(p, "#") || strings.Contains(p, ":") {
return errors.New("must be a safe plugin path")
}
return nil
}
func validateEndpointPath(value string) error {
p := strings.ReplaceAll(strings.TrimSpace(value), "\\", "/")
if p == "" || !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") || strings.Contains(p, "\x00") || strings.Contains(p, "..") || strings.ContainsAny(p, "*?#") {
return errors.New("must be a safe absolute endpoint path")
}
return nil
}
func validateRelativePath(value string) error {
p := strings.ReplaceAll(strings.TrimSpace(value), "\\", "/")
// Reject rooted paths, traversal, URL/drive syntax, and glob/query fragments.
if p == "" || strings.HasPrefix(p, "/") || strings.Contains(p, "\x00") || strings.Contains(p, "..") || strings.Contains(p, "*") || strings.Contains(p, "?") || strings.Contains(p, "#") || strings.Contains(p, ":") {
return errors.New("must be a safe relative path")
}
return nil
}
func validateUIEntrypoint(value string) error {
p := strings.TrimSpace(strings.ReplaceAll(value, "\\", "/"))
if p == "/admin" || p == "/admin/" {
return nil
}
return validateRelativePath(p)
}
func validateAllowlistEntry(entry string) error {
parts := strings.Fields(entry)
if len(parts) != 2 || !methodPattern.MatchString(parts[0]) {
return fmt.Errorf("invalid core_api_allowlist entry: %s", entry)
}
p := parts[1]
if !strings.HasPrefix(p, "/api/v1/") || strings.ContainsAny(p, "?#*") || strings.Contains(p, "//") || strings.Contains(p, "..") {
return fmt.Errorf("core_api_allowlist entry must be an exact Core path: %s", entry)
}
for _, segment := range strings.Split(strings.TrimPrefix(p, "/"), "/") {
if strings.Contains(segment, "{") || strings.Contains(segment, "}") {
if !pathParam.MatchString(segment) {
return fmt.Errorf("invalid path parameter in allowlist entry: %s", entry)
}
}
}
return nil
}
func VerifySignature(manifestBytes, signatureBytes, publicKeyBytes []byte) error {
if err := rejectDuplicateJSONKeys(signatureBytes); err != nil {
return err
}
var signature Signature
dec := json.NewDecoder(strings.NewReader(string(signatureBytes)))
dec.DisallowUnknownFields()
if err := dec.Decode(&signature); err != nil {
return fmt.Errorf("decode signature: %w", err)
}
var trailing any
if err := dec.Decode(&trailing); err != io.EOF {
return errors.New("signature contains trailing JSON")
}
if signature.Algorithm != "ed25519" || strings.TrimSpace(signature.KeyID) == "" {
return errors.New("signature must use ed25519 and include key_id")
}
publicKey, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(publicKeyBytes)))
if err != nil || len(publicKey) != ed25519.PublicKeySize {
return errors.New("invalid base64 ed25519 public key")
}
sig, err := base64.StdEncoding.DecodeString(signature.Signature)
if err != nil || len(sig) != ed25519.SignatureSize {
return errors.New("invalid base64 ed25519 signature")
}
if !ed25519.Verify(ed25519.PublicKey(publicKey), manifestBytes, sig) {
return errors.New("manifest signature verification failed")
}
return nil
}
func VerifyKeyID(signatureBytes []byte, expectedKeyID string) error {
if err := rejectDuplicateJSONKeys(signatureBytes); err != nil {
return err
}
var signature Signature
dec := json.NewDecoder(strings.NewReader(string(signatureBytes)))
dec.DisallowUnknownFields()
if err := dec.Decode(&signature); err != nil {
return fmt.Errorf("decode signature: %w", err)
}
var trailing any
if err := dec.Decode(&trailing); err != io.EOF {
return errors.New("signature contains trailing JSON")
}
if strings.TrimSpace(expectedKeyID) == "" || signature.KeyID != expectedKeyID {
return errors.New("signature key_id does not match manifest publisher")
}
return nil
}
// rejectDuplicateJSONKeys performs a token-level walk because encoding/json
// otherwise accepts duplicate object members and silently keeps the last one.
func rejectDuplicateJSONKeys(raw []byte) error {
dec := json.NewDecoder(bytes.NewReader(raw))
var walk func() error
walk = func() error {
tok, err := dec.Token()
if err != nil {
return err
}
delim, ok := tok.(json.Delim)
if !ok {
return nil
}
switch delim {
case '{':
seen := map[string]struct{}{}
for dec.More() {
key, err := dec.Token()
if err != nil {
return err
}
name, ok := key.(string)
if !ok {
return errors.New("object member name must be a string")
}
if _, exists := seen[name]; exists {
return fmt.Errorf("duplicate JSON object key: %s", name)
}
seen[name] = struct{}{}
if err := walk(); err != nil {
return err
}
}
end, err := dec.Token()
if err != nil {
return err
}
if end != json.Delim('}') {
return errors.New("invalid JSON object")
}
case '[':
for dec.More() {
if err := walk(); err != nil {
return err
}
}
end, err := dec.Token()
if err != nil {
return err
}
if end != json.Delim(']') {
return errors.New("invalid JSON array")
}
}
return nil
}
if err := walk(); err != nil {
return fmt.Errorf("invalid JSON: %w", err)
}
var trailing any
if err := dec.Decode(&trailing); err != io.EOF {
if err == nil {
return errors.New("JSON contains trailing data")
}
return fmt.Errorf("invalid JSON trailing data: %w", err)
}
return nil
}
func RequiredAllowlist() []string { return append([]string(nil), requiredAllowlist...) }
func (m Manifest) SortedCapabilities() []string {
out := append([]string(nil), m.Capabilities...)
sort.Strings(out)
return out
}
// EvaluateCompatibility applies the V1 rule that an untested Core is compatible
// but must remain disabled until an administrator explicitly accepts it.
func (m Manifest) EvaluateCompatibility(coreVersion string) Compatibility {
coreVersion = strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(coreVersion), "sub2api-"), "v")
baseline := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(m.CoreAPIBaseline), "sub2api-"), "v")
if coreVersion == "" || baseline == "" || coreVersion != baseline {
return Compatibility{Status: "incompatible", Message: "Core version does not match plugin baseline"}
}
tested := false
for _, version := range m.TestedCoreVersions {
v := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(version), "sub2api-"), "v")
if v == coreVersion {
tested = true
break
}
}
if !tested {
return Compatibility{Compatible: true, Status: "untested", Message: "Core version is compatible but not tested"}
}
return Compatibility{Compatible: true, Tested: true, Status: "compatible", Message: "Core version is tested"}
}
@@ -0,0 +1,119 @@
package manifest
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func validManifest() Manifest {
return Manifest{
SchemaVersion: 1, PluginID: "qiu.plugin-admin", Name: "Plugin Admin", Version: "1.0.0",
CoreAPIBaseline: "sub2api-0.1.183", Capabilities: []string{"plugin.admin.v1", "diagnostics.v1"},
TestedCoreVersions: []string{"0.1.183"},
Backend: Backend{HealthPath: "/healthz", ReadinessPath: "/readyz", ListenEnv: "PLUGIN_PORT"},
UI: UI{Entrypoint: "/admin/", Menu: Menu{ID: "qiu.plugin-admin", Label: "Plugin Admin", Visibility: "admin", SortOrder: 200}},
Publisher: Publisher{KeyID: "publisher-key"}, CoreAPIAllowlist: RequiredAllowlist(),
}
}
func TestValidateManifestAndRejectsUnsafeEntries(t *testing.T) {
m := validManifest()
if err := Validate(m); err != nil {
t.Fatal(err)
}
for name, mutate := range map[string]func(*Manifest){
"duplicate allowlist": func(m *Manifest) { m.CoreAPIAllowlist = append(m.CoreAPIAllowlist, m.CoreAPIAllowlist[0]) },
"wildcard": func(m *Manifest) { m.CoreAPIAllowlist = append(m.CoreAPIAllowlist, "GET /api/v1/admin/*") },
"menu mismatch": func(m *Manifest) { m.UI.Menu.ID = "other.plugin" },
"traversal": func(m *Manifest) { m.UI.Entrypoint = "/admin/../secret" },
"entrypoint URL": func(m *Manifest) { m.UI.Entrypoint = "https://example.invalid/ui.js" },
"file drive path": func(m *Manifest) { m.Files = map[string]string{"C:/plugin.js": strings.Repeat("a", 64)} },
"file traversal": func(m *Manifest) { m.Files = map[string]string{"ui/../plugin.js": strings.Repeat("a", 64)} },
} {
t.Run(name, func(t *testing.T) {
candidate := m
mutate(&candidate)
if err := Validate(candidate); err == nil {
t.Fatal("expected validation error")
}
})
}
}
func TestLoadRejectsUnknownAndTrailingJSON(t *testing.T) {
dir := t.TempDir()
for name, raw := range map[string]string{
"unknown": `{"schema_version":1,"extra":true}`,
"trailing": `{"schema_version":1} {}`,
"duplicate": `{"schema_version":1,"schema_version":1}`,
} {
path := filepath.Join(dir, name+".json")
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
if _, _, err := Load(path); err == nil {
t.Fatalf("%s: expected error", name)
}
}
}
func TestLoadRejectsNestedDuplicateJSONKeys(t *testing.T) {
dir := t.TempDir()
raw := `{"schema_version":1,"backend":{"health_path":"/healthz","health_path":"/readyz"}}`
path := filepath.Join(dir, "nested-duplicate.json")
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
if _, _, err := Load(path); err == nil {
t.Fatal("expected nested duplicate key error")
}
}
func TestSignatureAndKeyID(t *testing.T) {
publicKey, privateKey, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
manifestBytes := []byte(`{"schema_version":1}`)
signature := Signature{Algorithm: "ed25519", KeyID: "publisher-key", Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, manifestBytes))}
signatureBytes, err := json.Marshal(signature)
if err != nil {
t.Fatal(err)
}
publicKeyBytes := []byte(base64.StdEncoding.EncodeToString(publicKey))
if err := VerifyKeyID(signatureBytes, "publisher-key"); err != nil {
t.Fatal(err)
}
if err := VerifySignature(manifestBytes, signatureBytes, publicKeyBytes); err != nil {
t.Fatal(err)
}
if err := VerifySignature([]byte(`{"schema_version":2}`), signatureBytes, publicKeyBytes); err == nil {
t.Fatal("expected tamper failure")
}
if err := VerifyKeyID([]byte(strings.TrimSuffix(string(signatureBytes), "}")+"}{}"), "publisher-key"); err == nil {
t.Fatal("expected trailing signature failure")
}
duplicate := []byte(`{"algorithm":"ed25519","algorithm":"ed25519","key_id":"publisher-key","signature":""}`)
if err := VerifyKeyID(duplicate, "publisher-key"); err == nil {
t.Fatal("expected duplicate signature key failure")
}
}
func TestCompatibility(t *testing.T) {
m := validManifest()
if got := m.EvaluateCompatibility("sub2api-0.1.183"); !got.Compatible || !got.Tested || got.Status != "compatible" {
t.Fatalf("got %#v", got)
}
m.TestedCoreVersions = nil
if got := m.EvaluateCompatibility("0.1.183"); !got.Compatible || got.Tested || got.Status != "untested" {
t.Fatalf("got %#v", got)
}
if got := m.EvaluateCompatibility("0.1.184"); got.Compatible || got.Status != "incompatible" {
t.Fatalf("got %#v", got)
}
}
File diff suppressed because it is too large Load Diff
+990
View File
@@ -0,0 +1,990 @@
package main
import (
"archive/zip"
"bytes"
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"git.awaioi.com/awaioi/sub2api-add/plugins/plugin-admin/internal/manifest"
)
func testCore(t *testing.T, handler http.Handler) (*coreClient, *httptest.Server) {
t.Helper()
server := httptest.NewServer(handler)
client, err := newCoreClient(server.URL)
if err != nil {
server.Close()
t.Fatal(err)
}
return client, server
}
func adminSession(a *app) *http.Cookie {
now := time.Now()
id := "session"
a.sessions[id] = session{AccessToken: "ACCESS", RefreshToken: "REFRESH", CSRFToken: "CSRF", User: map[string]any{"id": float64(1), "role": "admin", "email": "admin@example.com"}, CreatedAt: now, LastSeen: now}
a.sessionLocks[id] = &sync.Mutex{}
return &http.Cookie{Name: sessionCookieName, Value: id}
}
func validPackage(t *testing.T, id string) []byte {
return validPackageVersion(t, id, "1.0.0")
}
func validPackageVersion(t *testing.T, id, version string) []byte {
t.Helper()
ui := []byte("<!doctype html><title>plugin</title>")
manifestValue := map[string]any{
"schema_version": 1,
"plugin_id": id,
"name": "Example Plugin",
"version": version,
"core_api_baseline": "sub2api-0.1.183",
"tested_core_versions": []string{"0.1.183"},
"capabilities": []string{"example.v1"},
"backend": map[string]any{"health_path": "/healthz", "readiness_path": "/readyz", "listen_env": "PLUGIN_PORT"},
"ui": map[string]any{"entrypoint": "ui/index.html", "menu": map[string]any{"id": id, "label": "Example", "visibility": "admin", "sort_order": 200}},
"publisher": map[string]any{"key_id": "dev"},
"core_api_allowlist": []string{"POST /api/v1/auth/login", "POST /api/v1/auth/login/2fa", "POST /api/v1/auth/refresh", "POST /api/v1/auth/logout", "GET /api/v1/auth/me", "GET /api/v1/settings/public"},
}
manifestValue["files"] = map[string]string{"ui/index.html": sha256Hex(ui)}
manifestBytes, err := json.Marshal(manifestValue)
if err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for name, data := range map[string][]byte{"manifest.json": manifestBytes, "ui/index.html": ui} {
w, err := zw.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(data); err != nil {
t.Fatal(err)
}
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func signedPackage(t *testing.T, id, version string) ([]byte, ed25519.PublicKey) {
t.Helper()
raw := validPackageVersion(t, id, version)
zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
if err != nil {
t.Fatal(err)
}
entries := make(map[string][]byte, len(zr.File))
var manifestBytes []byte
for _, file := range zr.File {
reader, openErr := file.Open()
if openErr != nil {
t.Fatal(openErr)
}
data, readErr := io.ReadAll(reader)
_ = reader.Close()
if readErr != nil {
t.Fatal(readErr)
}
entries[file.Name] = data
if file.Name == "manifest.json" {
manifestBytes = data
}
}
publicKey, privateKey, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
signature := manifest.Signature{Algorithm: "ed25519", KeyID: "dev", Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, manifestBytes))}
signatureBytes, err := json.Marshal(signature)
if err != nil {
t.Fatal(err)
}
entries["signature.json"] = signatureBytes
var out bytes.Buffer
zw := zip.NewWriter(&out)
for name, data := range entries {
writer, createErr := zw.Create(name)
if createErr != nil {
t.Fatal(createErr)
}
if _, writeErr := writer.Write(data); writeErr != nil {
t.Fatal(writeErr)
}
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return out.Bytes(), publicKey
}
func uploadRequest(t *testing.T, path string, cookie *http.Cookie, csrf, idempotency string, archive []byte) *http.Request {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("package", "plugin.s2plugin")
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(archive); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, path, &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("X-CSRF-Token", csrf)
req.Header.Set("Idempotency-Key", idempotency)
req.AddCookie(cookie)
return req
}
func sha256Hex(value []byte) string {
sum := sha256.Sum256(value)
return hex.EncodeToString(sum[:])
}
func TestAdminLoginDoesNotExposeCoreTokens(t *testing.T) {
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/auth/login":
_, _ = io.WriteString(w, `{"code":0,"data":{"access_token":"CORE_ACCESS","refresh_token":"CORE_REFRESH"}}`)
case "/api/v1/auth/me":
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin","email":"admin@example.com","access_token":"LEAK"}}`)
default:
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
}
}))
defer coreServer.Close()
reg, err := openRegistry(t.TempDir())
if err != nil {
t.Fatal(err)
}
a := newApp(core, reg, t.TempDir())
request := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"email":"admin@example.com","password":"password"}`))
recorder := httptest.NewRecorder()
a.login(recorder, request)
if recorder.Code != http.StatusOK || strings.Contains(recorder.Body.String(), "CORE_ACCESS") || strings.Contains(recorder.Body.String(), "CORE_REFRESH") || strings.Contains(recorder.Body.String(), "LEAK") {
t.Fatalf("unexpected login response: %d %s", recorder.Code, recorder.Body.String())
}
if len(recorder.Result().Cookies()) != 1 || !recorder.Result().Cookies()[0].HttpOnly {
t.Fatalf("expected HttpOnly plugin cookie: %#v", recorder.Result().Cookies())
}
}
func TestDecodeJSONRejectsTrailingValuesAndOversizeBodies(t *testing.T) {
var input struct {
Name string `json:"name"`
}
trailing := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"name":"plugin"}{}`))
if err := decodeJSON(trailing, &input, 1<<20); err == nil {
t.Fatal("expected concatenated JSON to be rejected")
}
oversized := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"name":"plugin"}`))
if err := decodeJSON(oversized, &input, 4); err == nil {
t.Fatal("expected oversized JSON body to be rejected")
}
}
func TestOrdinaryCoreUserIsRejected(t *testing.T) {
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api/v1/auth/login" {
_, _ = io.WriteString(w, `{"code":0,"data":{"access_token":"A","refresh_token":"R"}}`)
return
}
_, _ = io.WriteString(w, `{"code":0,"data":{"id":2,"role":"user"}}`)
}))
defer coreServer.Close()
reg, _ := openRegistry(t.TempDir())
a := newApp(core, reg, t.TempDir())
recorder := httptest.NewRecorder()
a.login(recorder, httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"email":"user@example.com","password":"password"}`)))
if recorder.Code != http.StatusForbidden {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestPackageInspectionAndAtomicInstall(t *testing.T) {
t.Setenv("CORE_VERSION", "0.1.183")
reg, err := openRegistry(t.TempDir())
if err != nil {
t.Fatal(err)
}
a := newApp(nil, reg, filepath.Dir(reg.path))
a.allowUnsigned = true
raw := validPackage(t, "example.plugin")
info, err := a.inspectPackage(raw)
if err != nil {
t.Fatal(err)
}
p, err := a.installPackage(info)
if err != nil {
t.Fatal(err)
}
if p.State != "disabled" || p.ActiveRevision == "" {
t.Fatalf("unexpected installed record: %#v", p)
}
if _, err := os.Stat(filepath.Join(p.Revisions[0].Path, "ui", "index.html")); err != nil {
t.Fatal(err)
}
if _, err := a.inspectPackage(bytes.Replace(raw, []byte("ui/index.html"), []byte("../secret"), 1)); err == nil {
t.Fatal("expected invalid package")
}
}
func TestProductionPackageRequiresTrustedSignature(t *testing.T) {
t.Setenv("CORE_VERSION", "0.1.183")
reg, _ := openRegistry(t.TempDir())
a := newApp(nil, reg, t.TempDir())
a.allowUnsigned = false
if _, err := a.inspectPackage(validPackage(t, "unsigned.plugin")); err == nil {
t.Fatal("expected unsigned package rejection")
}
raw, publicKey := signedPackage(t, "signed.plugin", "1.0.0")
a.trustedPublishers = map[string][]byte{"dev": publicKey}
if _, err := a.inspectPackage(raw); err != nil {
t.Fatalf("trusted signed package rejected: %v", err)
}
a.trustedPublishers = map[string][]byte{}
if _, err := a.inspectPackage(raw); err == nil {
t.Fatal("expected untrusted publisher rejection")
}
}
func TestDuplicateInstallCannotReplaceActiveRevision(t *testing.T) {
t.Setenv("CORE_VERSION", "0.1.183")
reg, _ := openRegistry(t.TempDir())
a := newApp(nil, reg, t.TempDir())
a.allowUnsigned = true
first, err := a.installPackage(a.packageForTest(t, "duplicate.plugin", "1.0.0"))
if err != nil {
t.Fatal(err)
}
secondInfo := a.packageForTest(t, "duplicate.plugin", "2.0.0")
if _, err := a.installPackage(secondInfo); err == nil || !strings.Contains(err.Error(), "already installed") {
t.Fatalf("expected duplicate install rejection, got %v", err)
}
reg.mu.Lock()
current := reg.data.Plugins["duplicate.plugin"]
reg.mu.Unlock()
if current.ActiveRevision != first.ActiveRevision || current.Manifest.Version != "1.0.0" {
t.Fatalf("duplicate install replaced active revision: %#v", current)
}
}
func TestConfigIsEncryptedAndSecretsAreNotReturned(t *testing.T) {
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
}))
defer coreServer.Close()
reg, _ := openRegistry(t.TempDir())
a := newApp(core, reg, t.TempDir())
p := pluginRecord{Manifest: manifest.Manifest{PluginID: "example.plugin", Name: "Example", Version: "1.0.0"}, State: "disabled", Revisions: []revision{}}
reg.data.Plugins[p.Manifest.PluginID] = p
now := time.Now()
a.sessions["sid"] = session{AccessToken: "A", RefreshToken: "R", CSRFToken: "CSRF", User: map[string]any{"id": float64(1), "role": "admin"}, CreatedAt: now, LastSeen: now}
a.sessionLocks["sid"] = &sync.Mutex{}
req := httptest.NewRequest(http.MethodPut, "/api/plugins/example.plugin/config", strings.NewReader(`{"service_url":"http://127.0.0.1:18090","client_secret":"TOP-SECRET"}`))
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
req.Header.Set("X-CSRF-Token", "CSRF")
req.Header.Set("Idempotency-Key", "config-1")
rec := httptest.NewRecorder()
a.config(rec, req)
if rec.Code != http.StatusAccepted || strings.Contains(rec.Body.String(), "TOP-SECRET") {
t.Fatalf("config response leaked secret: %d %s", rec.Code, rec.Body.String())
}
reg.mu.Lock()
stored := reg.data.Plugins[p.Manifest.PluginID].ConfigCipher
reg.mu.Unlock()
if stored == "" || strings.Contains(stored, "TOP-SECRET") {
t.Fatalf("config was not encrypted: %q", stored)
}
get := httptest.NewRequest(http.MethodGet, "/api/plugins/example.plugin/config", nil)
get.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
getRec := httptest.NewRecorder()
a.config(getRec, get)
if getRec.Code != http.StatusOK || strings.Contains(getRec.Body.String(), "TOP-SECRET") || !strings.Contains(getRec.Body.String(), "configured") {
t.Fatalf("config metadata response: %d %s", getRec.Code, getRec.Body.String())
}
}
func TestMutationRequiresCSRFAndIdempotency(t *testing.T) {
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
}))
defer coreServer.Close()
reg, _ := openRegistry(t.TempDir())
a := newApp(core, reg, t.TempDir())
a.sessions["sid"] = session{AccessToken: "A", RefreshToken: "R", CSRFToken: "CSRF", User: map[string]any{"id": float64(1), "role": "admin"}, CreatedAt: time.Now(), LastSeen: time.Now()}
a.sessionLocks["sid"] = &sync.Mutex{}
req := httptest.NewRequest(http.MethodPost, "/api/plugins/nope/enable", nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
req.Header.Set("Idempotency-Key", "enable-1")
rec := httptest.NewRecorder()
a.enable(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("missing csrf status=%d body=%s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest(http.MethodPost, "/api/plugins/nope/enable", nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
req.Header.Set("X-CSRF-Token", "CSRF")
rec = httptest.NewRecorder()
a.enable(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("missing idempotency status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestIdempotencyKeyRejectsDifferentOperationHash(t *testing.T) {
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
}))
defer coreServer.Close()
reg, _ := openRegistry(t.TempDir())
a := newApp(core, reg, t.TempDir())
a.sessions["sid"] = session{AccessToken: "A", RefreshToken: "R", CSRFToken: "CSRF", User: map[string]any{"id": float64(1), "role": "admin"}, CreatedAt: time.Now(), LastSeen: time.Now()}
a.sessionLocks["sid"] = &sync.Mutex{}
first := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/enable", strings.NewReader(`{"payload":"a"}`))
first.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
first.Header.Set("X-CSRF-Token", "CSRF")
first.Header.Set("Idempotency-Key", "same-key")
op, _, ok := a.mutationAuth(httptest.NewRecorder(), first, "enable", "example.plugin")
if !ok || op.ID == "" {
t.Fatal("first operation was not allocated")
}
second := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/enable", strings.NewReader(`{"payload":"b"}`))
second.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
second.Header.Set("X-CSRF-Token", "CSRF")
second.Header.Set("Idempotency-Key", "same-key")
rec := httptest.NewRecorder()
_, _, ok = a.mutationAuth(rec, second, "enable", "example.plugin")
if ok || rec.Code != http.StatusConflict {
t.Fatalf("expected idempotency conflict: status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestIdempotencyKeyReplaysSameBodyAndRetainsFailedOperation(t *testing.T) {
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
}))
defer coreServer.Close()
reg, _ := openRegistry(t.TempDir())
a := newApp(core, reg, t.TempDir())
a.sessions["sid"] = session{AccessToken: "A", RefreshToken: "R", CSRFToken: "CSRF", User: map[string]any{"id": float64(1), "role": "admin"}, CreatedAt: time.Now(), LastSeen: time.Now()}
a.sessionLocks["sid"] = &sync.Mutex{}
newRequest := func() *http.Request {
req := httptest.NewRequest(http.MethodPut, "/api/plugins/example.plugin/config", strings.NewReader(`{"service_url":"http://127.0.0.1:18090"}`))
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
req.Header.Set("X-CSRF-Token", "CSRF")
req.Header.Set("Idempotency-Key", "config-same")
return req
}
first, _, ok := a.mutationAuth(httptest.NewRecorder(), newRequest(), "config", "example.plugin")
if !ok {
t.Fatal("first operation was not allocated")
}
if _, err := a.registry.finishOperation(first, errors.New("expected failure")); err != nil {
t.Fatal(err)
}
secondRecorder := httptest.NewRecorder()
_, _, ok = a.mutationAuth(secondRecorder, newRequest(), "config", "example.plugin")
if ok || secondRecorder.Code != http.StatusAccepted || !strings.Contains(secondRecorder.Body.String(), first.ID) || !strings.Contains(secondRecorder.Body.String(), `"failed"`) {
t.Fatalf("expected failed operation replay: status=%d body=%s", secondRecorder.Code, secondRecorder.Body.String())
}
}
func TestRefreshRevalidatesAdminRole(t *testing.T) {
var meCalls int
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/auth/me":
meCalls++
if meCalls == 1 {
w.WriteHeader(http.StatusUnauthorized)
_, _ = io.WriteString(w, `{"code":401,"message":"expired"}`)
return
}
_, _ = io.WriteString(w, `{"code":0,"data":{"id":2,"role":"user"}}`)
case "/api/v1/auth/refresh":
_, _ = io.WriteString(w, `{"code":0,"data":{"access_token":"NEW","refresh_token":"NEW-R"}}`)
case "/api/v1/auth/logout":
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
default:
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
}
}))
defer coreServer.Close()
reg, _ := openRegistry(t.TempDir())
a := newApp(core, reg, t.TempDir())
now := time.Now()
a.sessions["sid"] = session{AccessToken: "OLD", RefreshToken: "R", CSRFToken: "CSRF", User: map[string]any{"id": float64(1), "role": "admin"}, CreatedAt: now, LastSeen: now}
a.sessionLocks["sid"] = &sync.Mutex{}
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
rec := httptest.NewRecorder()
a.me(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected demoted user rejection: status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestHealthProbeRejectsRedirectAndRequiresReadiness(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/healthz" {
http.Redirect(w, r, "http://127.0.0.1:1/internal", http.StatusFound)
return
}
_, _ = io.WriteString(w, `{"status":"ready","version":"1.0.0"}`)
}))
defer server.Close()
reg, _ := openRegistry(t.TempDir())
a := newApp(nil, reg, t.TempDir())
p := pluginRecord{Manifest: manifest.Manifest{PluginID: "example.plugin", Version: "1.0.0", Backend: manifest.Backend{HealthPath: "/healthz", ReadinessPath: "/readyz"}}, Endpoint: server.URL}
if err := a.checkPluginHealth(&p); err == nil {
t.Fatal("expected redirecting health endpoint to fail closed")
}
}
func TestRoutesSetSecurityHeadersAndProtectAPI(t *testing.T) {
reg, _ := openRegistry(t.TempDir())
a := newApp(nil, reg, t.TempDir())
server := httptest.NewServer(a.routes())
defer server.Close()
response, err := server.Client().Get(server.URL + "/api/plugins")
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusUnauthorized || response.Header.Get("Content-Security-Policy") == "" || response.Header.Get("X-Content-Type-Options") != "nosniff" {
t.Fatalf("status=%d headers=%v", response.StatusCode, response.Header)
}
}
func TestMenuPreviewAndApplyPreserveOtherMenuItems(t *testing.T) {
var applied []byte
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/auth/me":
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
case "/api/v1/admin/settings":
if r.Method == http.MethodPut {
applied, _ = io.ReadAll(r.Body)
}
_, _ = io.WriteString(w, `{"code":0,"data":{"custom_menu_items":[{"id":"core.home","label":"首页"}]}}`)
default:
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
}
}))
defer coreServer.Close()
reg, _ := openRegistry(t.TempDir())
a := newApp(core, reg, t.TempDir())
a.sessions["sid"] = session{AccessToken: "ACCESS", RefreshToken: "REFRESH", CSRFToken: "CSRF", User: map[string]any{"id": float64(1), "role": "admin"}, CreatedAt: time.Now(), LastSeen: time.Now()}
a.sessionLocks["sid"] = &sync.Mutex{}
menuURL := "http://127.0.0.1:18091"
reg.data.Plugins["example.plugin"] = pluginRecord{Manifest: manifest.Manifest{PluginID: "example.plugin", Name: "Example", Version: "1.0.0", UI: manifest.UI{Entrypoint: "ui/index.html", Menu: manifest.Menu{ID: "example.plugin", Label: "示例插件", Visibility: "admin", SortOrder: 200, URL: menuURL}}}, State: "healthy", ActiveRevision: "rev-1"}
cookie := &http.Cookie{Name: sessionCookieName, Value: "sid"}
preview := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/menu-preview", nil)
preview.AddCookie(cookie)
preview.Header.Set("X-CSRF-Token", "CSRF")
preview.Header.Set("Idempotency-Key", "menu-preview-1")
previewRec := httptest.NewRecorder()
a.menu(previewRec, preview, false)
if previewRec.Code != http.StatusOK || !strings.Contains(previewRec.Body.String(), "core.home") || !strings.Contains(previewRec.Body.String(), "example.plugin") {
t.Fatalf("unexpected menu preview: %d %s", previewRec.Code, previewRec.Body.String())
}
global := httptest.NewRequest(http.MethodPost, "/api/menu-items/preview", strings.NewReader(`{"plugin_id":"example.plugin"}`))
global.AddCookie(cookie)
global.Header.Set("X-CSRF-Token", "CSRF")
global.Header.Set("Idempotency-Key", "global-menu-preview-1")
globalRec := httptest.NewRecorder()
a.menuGlobal(globalRec, global, false)
if globalRec.Code != http.StatusOK || !strings.Contains(globalRec.Body.String(), "example.plugin") {
t.Fatalf("unexpected global menu preview: %d %s", globalRec.Code, globalRec.Body.String())
}
apply := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/menu-apply", nil)
apply.AddCookie(cookie)
apply.Header.Set("X-CSRF-Token", "CSRF")
apply.Header.Set("Idempotency-Key", "menu-apply-1")
applyRec := httptest.NewRecorder()
a.menu(applyRec, apply, true)
if applyRec.Code != http.StatusAccepted || len(applied) == 0 || !strings.Contains(string(applied), "core.home") || !strings.Contains(string(applied), "example.plugin") {
t.Fatalf("unexpected menu apply: %d body=%s request=%s", applyRec.Code, applyRec.Body.String(), applied)
}
}
func TestFailedUpgradeKeepsActiveRevision(t *testing.T) {
t.Setenv("CORE_VERSION", "0.1.183")
pluginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/healthz" || r.URL.Path == "/readyz" {
_, _ = io.WriteString(w, `{"status":"ok","version":"1.0.0"}`)
return
}
http.NotFound(w, r)
}))
defer pluginServer.Close()
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
}))
defer coreServer.Close()
reg, _ := openRegistry(t.TempDir())
a := newApp(core, reg, t.TempDir())
a.allowUnsigned = true
old, err := a.installPackage(a.packageForTest(t, "example.plugin", "1.0.0"))
if err != nil {
t.Fatal(err)
}
old.Endpoint = pluginServer.URL
old.State = "healthy"
reg.mu.Lock()
reg.data.Plugins["example.plugin"] = old
if err := reg.saveLocked(); err != nil {
reg.mu.Unlock()
t.Fatal(err)
}
reg.mu.Unlock()
a.sessions["sid"] = session{AccessToken: "ACCESS", RefreshToken: "REFRESH", CSRFToken: "CSRF", User: map[string]any{"id": float64(1), "role": "admin"}, CreatedAt: time.Now(), LastSeen: time.Now()}
a.sessionLocks["sid"] = &sync.Mutex{}
req := uploadRequest(t, "/api/plugins/example.plugin/upgrade", &http.Cookie{Name: sessionCookieName, Value: "sid"}, "CSRF", "upgrade-1", validPackageVersion(t, "example.plugin", "2.0.0"))
rec := httptest.NewRecorder()
a.install(rec, req, true, "example.plugin")
if rec.Code != http.StatusAccepted || !strings.Contains(rec.Body.String(), "operation_id") {
t.Fatalf("unexpected upgrade response: %d %s", rec.Code, rec.Body.String())
}
reg.mu.Lock()
current := reg.data.Plugins["example.plugin"]
reg.mu.Unlock()
if current.ActiveRevision != old.ActiveRevision || current.PendingRevision == "" || current.State != "rollback_pending" || current.Manifest.Version != "1.0.0" {
t.Fatalf("active revision changed after failed upgrade: %#v", current)
}
pendingID := current.PendingRevision
var pendingPath string
for _, rev := range current.Revisions {
if rev.ID == pendingID {
pendingPath = rev.Path
}
}
if pendingPath == "" {
t.Fatal("failed upgrade did not retain pending revision path")
}
rollback := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/rollback", nil)
rollback.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
rollback.Header.Set("X-CSRF-Token", "CSRF")
rollback.Header.Set("Idempotency-Key", "rollback-after-failure")
rollbackRec := httptest.NewRecorder()
a.rollback(rollbackRec, rollback)
if rollbackRec.Code != http.StatusAccepted {
t.Fatalf("rollback failed: %d %s", rollbackRec.Code, rollbackRec.Body.String())
}
reg.mu.Lock()
restored := reg.data.Plugins["example.plugin"]
reg.mu.Unlock()
if restored.ActiveRevision != old.ActiveRevision || restored.PendingRevision != "" || restored.State != "healthy" || restored.Manifest.Version != "1.0.0" {
t.Fatalf("failed-upgrade rollback did not restore old revision: %#v", restored)
}
var retired bool
for _, rev := range restored.Revisions {
if rev.ID == pendingID {
retired = rev.Retired
}
}
if !retired {
t.Fatal("failed candidate was not marked retired")
}
if _, err := os.Stat(pendingPath); err != nil {
t.Fatalf("retired candidate path was removed before registry commit: %v", err)
}
}
func TestLifecycleEnableDisableUninstall(t *testing.T) {
t.Setenv("CORE_VERSION", "0.1.183")
var applied []byte
pluginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/healthz" || r.URL.Path == "/readyz" {
_, _ = io.WriteString(w, `{"status":"ok","version":"1.0.0"}`)
return
}
http.NotFound(w, r)
}))
defer pluginServer.Close()
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/auth/me":
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
case "/api/v1/admin/settings":
if r.Method == http.MethodPut {
applied, _ = io.ReadAll(r.Body)
}
_, _ = io.WriteString(w, `{"code":0,"data":{"custom_menu_items":[{"id":"core.home","label":"首页"},{"id":"example.plugin","label":"示例"}]}}`)
default:
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
}
}))
defer coreServer.Close()
reg, _ := openRegistry(t.TempDir())
a := newApp(core, reg, t.TempDir())
a.allowUnsigned = true
p, err := a.installPackage(a.packageForTest(t, "example.plugin", "1.0.0"))
if err != nil {
t.Fatal(err)
}
p.Endpoint = pluginServer.URL
reg.mu.Lock()
reg.data.Plugins[p.Manifest.PluginID] = p
reg.mu.Unlock()
cookie := adminSession(a)
enable := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/enable", nil)
enable.AddCookie(cookie)
enable.Header.Set("X-CSRF-Token", "CSRF")
enable.Header.Set("Idempotency-Key", "enable-lifecycle")
enableRec := httptest.NewRecorder()
a.enable(enableRec, enable)
if enableRec.Code != http.StatusAccepted {
t.Fatalf("enable failed: %d %s", enableRec.Code, enableRec.Body.String())
}
reg.mu.Lock()
if reg.data.Plugins["example.plugin"].State != "healthy" {
t.Fatalf("plugin did not become healthy: %#v", reg.data.Plugins["example.plugin"])
}
reg.mu.Unlock()
disable := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/disable", nil)
disable.AddCookie(cookie)
disable.Header.Set("X-CSRF-Token", "CSRF")
disable.Header.Set("Idempotency-Key", "disable-lifecycle")
disableRec := httptest.NewRecorder()
a.disable(disableRec, disable)
if disableRec.Code != http.StatusAccepted || strings.Contains(string(applied), "example.plugin") {
t.Fatalf("disable did not remove own menu: %d body=%s request=%s", disableRec.Code, disableRec.Body.String(), applied)
}
uninstall := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/uninstall", nil)
uninstall.AddCookie(cookie)
uninstall.Header.Set("X-CSRF-Token", "CSRF")
uninstall.Header.Set("Idempotency-Key", "uninstall-lifecycle")
uninstallRec := httptest.NewRecorder()
a.uninstall(uninstallRec, uninstall)
if uninstallRec.Code != http.StatusAccepted {
t.Fatalf("uninstall failed: %d %s", uninstallRec.Code, uninstallRec.Body.String())
}
reg.mu.Lock()
_, exists := reg.data.Plugins["example.plugin"]
reg.mu.Unlock()
if exists {
t.Fatal("plugin remained in registry after uninstall")
}
}
func TestUninstallRegistryFailureRestoresRevisionPath(t *testing.T) {
t.Setenv("CORE_VERSION", "0.1.183")
root := t.TempDir()
registryDir := t.TempDir()
reg, err := openRegistry(registryDir)
if err != nil {
t.Fatal(err)
}
pluginID := "restore.plugin"
revisionPath := filepath.Join(root, "installed", pluginID, "rev-1")
if err := os.MkdirAll(revisionPath, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(revisionPath, "marker"), []byte("keep"), 0o600); err != nil {
t.Fatal(err)
}
reg.data.Plugins[pluginID] = pluginRecord{
Manifest: manifest.Manifest{
PluginID: pluginID,
Name: "Restore",
Version: "1.0.0",
CoreAPIBaseline: "sub2api-0.1.183",
TestedCoreVersions: []string{"0.1.183"},
Backend: manifest.Backend{HealthPath: "/healthz", ReadinessPath: "/readyz", ListenEnv: "PLUGIN_PORT"},
UI: manifest.UI{Entrypoint: "ui/index.html", Menu: manifest.Menu{ID: pluginID, Label: "Restore", Visibility: "admin", SortOrder: 200, URL: "http://127.0.0.1:8090"}},
},
State: "disabled",
ActiveRevision: "rev-1",
Revisions: []revision{{ID: "rev-1", Version: "1.0.0", Path: revisionPath}},
UpdatedAt: time.Now().UTC(),
}
if err := reg.save(); err != nil {
t.Fatal(err)
}
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/auth/me":
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
case "/api/v1/admin/settings":
if r.Method == http.MethodPut {
// Force the lifecycle registry commit to fail after the menu
// update, exercising path restoration as well as record rollback.
reg.path = t.TempDir()
}
_, _ = io.WriteString(w, `{"code":0,"data":{"custom_menu_items":[{"id":"`+pluginID+`","label":"Restore"}]}}`)
default:
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
}
}))
defer coreServer.Close()
a := newApp(core, reg, root)
cookie := adminSession(a)
req := httptest.NewRequest(http.MethodPost, "/api/plugins/"+pluginID+"/uninstall", nil)
req.AddCookie(cookie)
req.Header.Set("X-CSRF-Token", "CSRF")
req.Header.Set("Idempotency-Key", "uninstall-restore")
rec := httptest.NewRecorder()
a.uninstall(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected persistence failure, got %d body=%s", rec.Code, rec.Body.String())
}
reg.mu.Lock()
restored, exists := reg.data.Plugins[pluginID]
reg.mu.Unlock()
if !exists || len(restored.Revisions) != 1 || restored.Revisions[0].Path != revisionPath {
t.Fatalf("registry record was not restored: exists=%v record=%#v", exists, restored)
}
if _, err := os.Stat(filepath.Join(revisionPath, "marker")); err != nil {
t.Fatalf("revision path was not restored: %v", err)
}
}
func TestPluginLockSerializesLifecycleMutations(t *testing.T) {
reg, _ := openRegistry(t.TempDir())
a := newApp(nil, reg, t.TempDir())
firstEntered := make(chan struct{})
release := make(chan struct{})
secondEntered := make(chan struct{})
go func() {
unlock := a.lockPlugin("example.plugin")
close(firstEntered)
<-release
unlock()
}()
<-firstEntered
go func() {
unlock := a.lockPlugin("example.plugin")
close(secondEntered)
unlock()
}()
select {
case <-secondEntered:
t.Fatal("second plugin mutation acquired the lock concurrently")
case <-time.After(25 * time.Millisecond):
}
close(release)
select {
case <-secondEntered:
case <-time.After(time.Second):
t.Fatal("second plugin mutation did not proceed after release")
}
}
func TestRecoverExternalPluginAfterRestart(t *testing.T) {
t.Setenv("CORE_VERSION", "0.1.183")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/healthz" && r.URL.Path != "/readyz" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"status":"ok","version":"1.0.0"}`)
}))
defer server.Close()
reg, _ := openRegistry(t.TempDir())
a := newApp(nil, reg, t.TempDir())
p := pluginRecord{Manifest: manifest.Manifest{PluginID: "external.plugin", Name: "External", Version: "1.0.0", CoreAPIBaseline: "sub2api-0.1.183", TestedCoreVersions: []string{"0.1.183"}, Backend: manifest.Backend{HealthPath: "/healthz", ReadinessPath: "/readyz"}}, State: "healthy", ActiveRevision: "rev-1", Endpoint: server.URL, Revisions: []revision{{ID: "rev-1", Version: "1.0.0", Manifest: manifest.Manifest{PluginID: "external.plugin", Name: "External", Version: "1.0.0", CoreAPIBaseline: "sub2api-0.1.183", TestedCoreVersions: []string{"0.1.183"}, Backend: manifest.Backend{HealthPath: "/healthz", ReadinessPath: "/readyz"}}}}}
reg.data.Plugins[p.Manifest.PluginID] = p
if err := reg.save(); err != nil {
t.Fatal(err)
}
if err := a.recoverPlugins(); err != nil {
t.Fatal(err)
}
reg.mu.Lock()
recovered := reg.data.Plugins[p.Manifest.PluginID]
reg.mu.Unlock()
if recovered.State != "healthy" || recovered.Endpoint != server.URL || recovered.LastError != "" {
t.Fatalf("external plugin was not recovered: %#v", recovered)
}
}
func TestRecoverCommandPluginAfterRestart(t *testing.T) {
t.Setenv("CORE_VERSION", "0.1.183")
if _, err := os.Stat("/bin/sh"); err != nil {
t.Skip("shell is unavailable")
}
root := t.TempDir()
pluginDir := filepath.Join(root, "installed", "command.plugin", "rev-1")
if err := os.MkdirAll(filepath.Join(pluginDir, "service"), 0o700); err != nil {
t.Fatal(err)
}
// The helper is a tiny Python HTTP server available in the local test
// environment; it binds the supervisor-provided loopback port.
command := filepath.Join(pluginDir, "service", "run.py")
source := "#!/usr/bin/env python3\nimport http.server, os\nclass H(http.server.BaseHTTPRequestHandler):\n def do_GET(self):\n if self.path in ('/healthz','/readyz'):\n body=b'{\\\"status\\\":\\\"ok\\\",\\\"version\\\":\\\"1.0.0\\\"}'\n self.send_response(200); self.send_header('Content-Type','application/json'); self.send_header('Content-Length',str(len(body))); self.end_headers(); self.wfile.write(body)\n else: self.send_response(404); self.end_headers()\n def log_message(self,*args): pass\nhttp.server.HTTPServer(('127.0.0.1', int(os.environ['PLUGIN_PORT'])), H).serve_forever()\n"
if err := os.WriteFile(command, []byte(source), 0o700); err != nil {
t.Fatal(err)
}
pythonPath, err := exec.LookPath("python3")
if err != nil {
t.Skip("python3 is unavailable")
}
source = strings.Replace(source, "#!/usr/bin/env python3", "#!"+pythonPath, 1)
reg, _ := openRegistry(filepath.Join(root, "registry"))
pluginManifest := manifest.Manifest{PluginID: "command.plugin", Name: "Command", Version: "1.0.0", CoreAPIBaseline: "sub2api-0.1.183", TestedCoreVersions: []string{"0.1.183"}, Backend: manifest.Backend{HealthPath: "/healthz", ReadinessPath: "/readyz", Command: "service/run.py", ListenEnv: "PLUGIN_PORT"}}
reg.data.Plugins[pluginManifest.PluginID] = pluginRecord{Manifest: pluginManifest, State: "healthy", ActiveRevision: "rev-1", Revisions: []revision{{ID: "rev-1", Version: "1.0.0", Path: pluginDir, Manifest: pluginManifest}}}
if err := reg.save(); err != nil {
t.Fatal(err)
}
a := newApp(nil, reg, root)
if err := a.recoverPlugins(); err != nil {
t.Fatal(err)
}
reg.mu.Lock()
recovered := reg.data.Plugins[pluginManifest.PluginID]
reg.mu.Unlock()
if recovered.State != "healthy" || !strings.HasPrefix(recovered.Endpoint, "http://127.0.0.1:") {
t.Fatalf("command plugin was not recovered: %#v", recovered)
}
a.stopPlugin(pluginManifest.PluginID)
}
func TestRegistryPersistenceErrorsAreObservable(t *testing.T) {
reg, _ := openRegistry(t.TempDir())
reg.path = t.TempDir()
if _, _, _, err := reg.operation("enable", "example.plugin", "key", 1, "rid", "hash"); err == nil {
t.Fatal("expected operation persistence error")
}
if len(reg.data.Operations) != 0 {
t.Fatal("failed operation allocation remained in memory")
}
reg.path = filepath.Join(t.TempDir(), "registry.json")
op, _, _, err := reg.operation("enable", "example.plugin", "key", 1, "rid", "hash")
if err != nil {
t.Fatal(err)
}
reg.path = t.TempDir()
if _, err := reg.finishOperation(op, nil); err == nil {
t.Fatal("expected operation finish persistence error")
}
reg.data.Audit = nil
if err := reg.addAudit(auditEvent{Action: "test"}); err == nil {
t.Fatal("expected audit persistence error")
}
}
func TestUpgradeDoesNotCarryEndpointAcrossServiceModes(t *testing.T) {
reg, _ := openRegistry(t.TempDir())
a := newApp(nil, reg, t.TempDir())
base := manifest.Manifest{PluginID: "mode.plugin", Name: "Mode", Version: "1.0.0", CoreAPIBaseline: "sub2api-0.1.183", TestedCoreVersions: []string{"0.1.183"}, Backend: manifest.Backend{HealthPath: "/healthz", ReadinessPath: "/readyz", ListenEnv: "PLUGIN_PORT", Command: "service/plugin"}}
old := pluginRecord{Manifest: base, State: "disabled", ActiveRevision: "old", Endpoint: "http://127.0.0.1:59001", Revisions: []revision{{ID: "old", Version: "1.0.0", Manifest: base}}}
reg.data.Plugins[base.PluginID] = old
newManifest := base
newManifest.Version = "2.0.0"
newManifest.Backend.Command = ""
newInfo := packageInfo{Manifest: newManifest, Archive: []byte("external"), Files: map[string][]byte{"ui/index.html": []byte("<title>mode</title>")}}
newManifest.Files = map[string]string{"ui/index.html": sha256Hex(newInfo.Files["ui/index.html"])}
newInfo.Manifest = newManifest
upgraded, err := a.installPackageMode(newInfo, true)
if err != nil {
t.Fatal(err)
}
if upgraded.Endpoint != "" {
t.Fatalf("command endpoint leaked into external revision: %q", upgraded.Endpoint)
}
externalBase := base
externalBase.Backend.Command = ""
externalBase.Version = "2.0.0"
reg.data.Plugins[base.PluginID] = pluginRecord{Manifest: externalBase, State: "disabled", ActiveRevision: "external", Endpoint: "http://127.0.0.1:59001", Revisions: []revision{{ID: "external", Version: "2.0.0", Manifest: externalBase}}}
commandManifest := newManifest
commandManifest.Version = "3.0.0"
commandManifest.Backend.Command = "service/plugin"
commandInfo := packageInfo{Manifest: commandManifest, Archive: []byte("command"), Files: map[string][]byte{"service/plugin": []byte("#!/bin/sh\nexit 0"), "ui/index.html": []byte("<title>mode</title>")}}
commandManifest.Files = map[string]string{"service/plugin": sha256Hex(commandInfo.Files["service/plugin"]), "ui/index.html": sha256Hex(commandInfo.Files["ui/index.html"])}
commandInfo.Manifest = commandManifest
commandUpgraded, err := a.installPackageMode(commandInfo, true)
if err != nil {
t.Fatal(err)
}
if commandUpgraded.Endpoint != "" {
t.Fatalf("external endpoint leaked into command revision: %q", commandUpgraded.Endpoint)
}
}
func TestTwoFactorLoginCreatesAdminSession(t *testing.T) {
var login2FACalled bool
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/auth/login":
_, _ = io.WriteString(w, `{"code":0,"data":{"requires_2fa":true,"temp_token":"TEMP"}}`)
case "/api/v1/auth/login/2fa":
login2FACalled = true
_, _ = io.WriteString(w, `{"code":0,"data":{"access_token":"ACCESS","refresh_token":"REFRESH"}}`)
case "/api/v1/auth/me":
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin","email":"admin@example.com"}}`)
case "/api/v1/auth/logout":
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
default:
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
}
}))
defer coreServer.Close()
reg, _ := openRegistry(t.TempDir())
a := newApp(core, reg, t.TempDir())
loginRec := httptest.NewRecorder()
a.login(loginRec, httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"email":"admin@example.com","password":"password"}`)))
if loginRec.Code != http.StatusOK || !strings.Contains(loginRec.Body.String(), "pending_token") {
t.Fatalf("expected 2fa challenge: %d %s", loginRec.Code, loginRec.Body.String())
}
var challenge struct {
PendingToken string `json:"pending_token"`
}
if err := json.Unmarshal(loginRec.Body.Bytes(), &challenge); err != nil || challenge.PendingToken == "" {
t.Fatalf("challenge token missing: %s", loginRec.Body.String())
}
body := fmt.Sprintf(`{"pending_token":%q,"totp_code":"123456"}`, challenge.PendingToken)
verifyRec := httptest.NewRecorder()
a.login2FA(verifyRec, httptest.NewRequest(http.MethodPost, "/login/2fa", strings.NewReader(body)))
if verifyRec.Code != http.StatusOK || !login2FACalled || len(verifyRec.Result().Cookies()) != 1 {
t.Fatalf("2fa login failed: %d %s", verifyRec.Code, verifyRec.Body.String())
}
}
func (a *app) packageForTest(t *testing.T, id, version string) packageInfo {
t.Helper()
raw := validPackageVersion(t, id, version)
info, err := a.inspectPackage(raw)
if err != nil {
t.Fatal(err)
}
return info
}
@@ -0,0 +1,40 @@
import { chromium } from 'playwright'
import fs from 'node:fs/promises'
import path from 'node:path'
const origin = process.env.PLUGIN_BROWSER_ORIGIN || 'http://127.0.0.1:18090'
const entry = `${origin}/admin/`
const outputDir = path.resolve(process.env.PLUGIN_SCREENSHOT_DIR || '.playwright-cli/plugin-admin')
const email = process.env.PLUGIN_TEST_EMAIL || 'admin@example.com'
const password = process.env.PLUGIN_TEST_PASSWORD || 'password'
await fs.mkdir(outputDir, { recursive: true })
const browser = await chromium.launch({ headless: true })
try {
for (const width of [425, 900, 1440]) {
const page = await browser.newPage({ viewport: { width, height: 900 }, deviceScaleFactor: 1 })
const responseLeaks = []
page.on('response', async (response) => {
if (!response.headers()['content-type']?.includes('application/json')) return
try {
const body = await response.text()
if (/access_token|refresh_token|admin[_-]?api[_-]?key|password|client_secret/i.test(body)) responseLeaks.push(response.url())
} catch (_) {}
})
await page.goto(entry, { waitUntil: 'networkidle' })
await page.getByLabel('邮箱').fill(email)
await page.getByLabel('密码').fill(password)
await page.getByRole('button', { name: '登录' }).click()
await page.getByRole('heading', { name: '已登记插件' }).waitFor()
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)
if (overflow) throw new Error(`horizontal overflow at ${width}px`)
const buttons = await page.locator('button, .file-button').evaluateAll((items) => items.filter((item) => item.getClientRects().length > 0 && getComputedStyle(item).visibility !== 'hidden').every((item) => item.getBoundingClientRect().height >= 28 && item.getBoundingClientRect().width >= 28))
if (!buttons) throw new Error(`control collapsed at ${width}px`)
await page.waitForTimeout(50)
if (responseLeaks.length) throw new Error(`sensitive response field exposed at ${width}px: ${responseLeaks.join(', ')}`)
await page.screenshot({ path: path.join(outputDir, `plugin-admin-${width}.png`), fullPage: true })
await page.close()
}
} finally {
await browser.close()
}
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env sh
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
PLUGIN_BROWSER_ORIGIN=${PLUGIN_BROWSER_ORIGIN:-http://127.0.0.1:18090} \
PLUGIN_SCREENSHOT_DIR=${PLUGIN_SCREENSHOT_DIR:-$ROOT/.screenshots/plugin-admin} \
node "$ROOT/test/browser-check.mjs"
+65
View File
@@ -0,0 +1,65 @@
(() => {
'use strict'
const base = (window.__PLUGIN_BASE_PATH__ || '').replace(/\/$/, '')
const $ = (selector) => document.querySelector(selector)
let csrf = ''
let pendingToken = ''
let configPluginId = ''
const notice = (message, error = false) => {
const el = $('#notice'); el.textContent = message || ''; el.className = error ? 'notice error' : 'notice'
}
const api = async (path, options = {}) => {
const headers = new Headers(options.headers || {})
headers.set('Accept', 'application/json')
if (options.body && !(options.body instanceof FormData)) headers.set('Content-Type', 'application/json')
if (csrf && options.method && options.method !== 'GET') headers.set('X-CSRF-Token', csrf)
const response = await fetch(`${base}${path}`, { ...options, headers, credentials: 'same-origin' })
const data = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(data.error || `请求失败 (${response.status})`)
return data
}
const setLoggedIn = (user) => {
$('#login-panel').hidden = !!user
$('#app-panel').hidden = !user
$('#logout').hidden = !user
$('#operator').textContent = user ? (user.email || user.username || '管理员') : ''
}
const login = async (event) => {
event.preventDefault(); $('#login-error').textContent = ''
const body = Object.fromEntries(new FormData(event.currentTarget).entries())
try {
const result = await api('/login', { method: 'POST', body: JSON.stringify(body) })
if (result.requires_2fa) { pendingToken = result.pending_token; $('#login-form').hidden = true; $('#twofa-form').hidden = false; return }
csrf = result.csrf_token; setLoggedIn(result.user); await loadAll()
} catch (error) { $('#login-error').textContent = error.message }
}
const login2fa = async (event) => {
event.preventDefault(); $('#login-error').textContent = ''
const body = Object.fromEntries(new FormData(event.currentTarget).entries()); body.pending_token = pendingToken
try { const result = await api('/login/2fa', { method: 'POST', body: JSON.stringify(body) }); csrf = result.csrf_token; setLoggedIn(result.user); await loadAll() } catch (error) { $('#login-error').textContent = error.message }
}
const logout = async () => { try { await api('/logout', { method: 'POST' }) } catch (_) {} csrf = ''; setLoggedIn(null); $('#login-form').hidden = false; $('#twofa-form').hidden = true }
const badge = (state) => `<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 loadPlugins = async () => {
const data = await api('/api/plugins'); const root = $('#plugins'); root.textContent = ''
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>`
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 openConfig = async (id) => { configPluginId = id; $('#config-plugin').textContent = id; $('#config-error').textContent = ''; const form = $('#config-form'); form.elements.service_url.value = ''; form.elements.public_url.value = ''; try { const data = await api(`/api/plugins/${encodeURIComponent(id)}/config`); form.elements.service_url.value = data.endpoint || ''; if (data.config && typeof data.config.public_url === 'string') form.elements.public_url.value = data.config.public_url; $('#config-dialog').showModal() } catch (error) { notice(error.message, true) } }
const saveConfig = async (event) => { event.preventDefault(); const form = event.currentTarget; const body = { service_url: form.elements.service_url.value.trim(), public_url: form.elements.public_url.value.trim() }; try { const result = await api(`/api/plugins/${encodeURIComponent(configPluginId)}/config`, { method: 'PUT', headers: { 'Idempotency-Key': `config-${configPluginId}-${Date.now()}` }, body: JSON.stringify(body) }); $('#config-dialog').close(); notice(`配置已保存:${result.operation_id || result.state}`); await loadAll() } catch (error) { $('#config-error').textContent = error.message } }
const upload = async (file) => { const form = new FormData(); form.append('package', file); try { const result = await api('/api/plugins/install', { method: 'POST', headers: { 'Idempotency-Key': `install-${Date.now()}` }, body: form }); notice(`安装已提交:${result.operation_id || result.state}`); await loadAll() } catch (error) { notice(error.message, true) } }
const uploadUpgrade = async (id, file) => { const form = new FormData(); form.append('package', file); try { const result = await api(`/api/plugins/${encodeURIComponent(id)}/upgrade`, { method: 'POST', headers: { 'Idempotency-Key': `upgrade-${id}-${Date.now()}` }, body: form }); notice(`升级已提交:${result.operation_id || result.state}`); await loadAll() } catch (error) { notice(error.message, true) } }
$('#login-form').addEventListener('submit', login); $('#twofa-form').addEventListener('submit', login2fa); $('#logout').addEventListener('click', logout); $('#refresh').addEventListener('click', loadAll); $('#audit-refresh').addEventListener('click', loadAudit); $('#config-form').addEventListener('submit', saveConfig); $('#config-close').addEventListener('click', () => $('#config-dialog').close()); $('#config-cancel').addEventListener('click', () => $('#config-dialog').close())
$('#package-file').addEventListener('change', (event) => { const file = event.target.files[0]; if (file) upload(file); event.target.value = '' })
$('#plugins').addEventListener('change', (event) => { const input = event.target.closest('[data-action="upgrade-file"]'); if (!input) return; const file = input.files[0]; if (file) uploadUpgrade(input.dataset.id, file); input.value = '' })
$('#plugins').addEventListener('click', (event) => { const button = event.target.closest('[data-action]'); if (!button || button.disabled) return; const action = button.dataset.action; const id = button.dataset.id; if (action === 'config') { openConfig(id); return } mutate(action, id) })
api('/api/me').then((data) => { csrf = data.csrf_token; setLoggedIn(data.user); loadAll() }).catch(() => setLoggedIn(null))
})()
+71
View File
@@ -0,0 +1,71 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>插件管理</title>
<link rel="stylesheet" href="../styles.css">
</head>
<body>
<main class="shell">
<header class="topbar">
<div>
<p class="eyebrow">SUB2API EXTENSIONS</p>
<h1>插件管理</h1>
<p class="muted">独立业务插件控制面</p>
</div>
<div class="top-actions">
<span id="operator" class="operator"></span>
<button id="logout" class="button button-quiet" hidden>退出</button>
</div>
</header>
<section id="login-panel" class="panel auth-panel">
<h2>管理员登录</h2>
<p class="muted">使用 Sub2API Core 管理员账号登录。普通账号没有访问权限。</p>
<form id="login-form" class="form-grid">
<label>邮箱<input name="email" type="email" autocomplete="username" required></label>
<label>密码<input name="password" type="password" autocomplete="current-password" required></label>
<button class="button" type="submit">登录</button>
</form>
<form id="twofa-form" class="form-grid" hidden>
<label>验证码<input name="totp_code" inputmode="numeric" maxlength="6" pattern="[0-9]{6}" required></label>
<button class="button" type="submit">验证并继续</button>
</form>
<p id="login-error" class="error" role="alert"></p>
</section>
<section id="app-panel" hidden>
<div class="toolbar">
<div>
<h2>已登记插件</h2>
<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>
<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="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>
</section>
</section>
</main>
<dialog id="config-dialog" class="config-dialog">
<form id="config-form" method="dialog" class="config-form">
<div class="section-heading"><h2>插件配置</h2><button id="config-close" class="button button-quiet" type="button">关闭</button></div>
<p id="config-plugin" class="muted mono"></p>
<label>服务地址<input name="service_url" type="url" placeholder="http://127.0.0.1:18090" required></label>
<label>菜单地址<input name="public_url" type="url" placeholder="https://CORE_ORIGIN/extensions/PLUGIN_ID/"></label>
<p class="muted">密钥只在服务端加密保存,页面不会回显原值。</p>
<div class="dialog-actions"><button id="config-cancel" class="button button-secondary" type="button">取消</button><button class="button" type="submit">保存配置</button></div>
<p id="config-error" class="error" role="alert"></p>
</form>
</dialog>
<script>window.__PLUGIN_BASE_PATH__ = __PLUGIN_BASE_PATH_JSON__;</script>
<script src="../app.js" defer></script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #17202a; background: #f4f6f8; }
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body { margin: 0; min-width: 320px; }
.shell { width: min(1120px, calc(100% - 32px)); margin: 0 auto; padding: 32px 0 56px; }
.topbar, .toolbar, .section-heading, .plugin-title, .card-actions, .top-actions { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.topbar { padding-bottom: 24px; border-bottom: 1px solid #d9dee5; }
.eyebrow { color: #5a6877; font-size: 11px; letter-spacing: .12em; margin: 0 0 6px; }
h1, h2, h3, p { margin-top: 0; } h1 { font-size: 28px; margin-bottom: 4px; } h2 { font-size: 18px; margin-bottom: 8px; } h3 { margin-bottom: 4px; font-size: 17px; }
.muted { color: #687585; font-size: 13px; } .operator { color: #475569; font-size: 13px; }
.panel { background: #fff; border: 1px solid #d9dee5; border-radius: 6px; box-shadow: 0 2px 8px rgb(15 23 42 / 4%); }
.auth-panel { max-width: 480px; margin: 48px auto 0; padding: 24px; }
.form-grid { display: grid; gap: 14px; margin-top: 20px; } label { display: grid; gap: 6px; color: #334155; font-size: 13px; }
input { width: 100%; height: 36px; padding: 0 10px; border: 1px solid #c7d0da; border-radius: 4px; background: #fff; color: inherit; font: inherit; } input:focus { outline: 2px solid #a7c7ff; outline-offset: 1px; }
.button, .file-button { display: inline-flex; align-items: center; justify-content: center; min-height: 36px; padding: 0 14px; border: 1px solid #2563eb; border-radius: 4px; background: #2563eb; color: #fff; cursor: pointer; font: inherit; font-size: 13px; white-space: nowrap; }
.button:hover { background: #1d4ed8; } .button:disabled { opacity: .45; cursor: not-allowed; } .button-secondary { background: #fff; color: #1e40af; border-color: #b9c8dc; } .button-secondary:hover, .button-quiet:hover { background: #f3f6fa; } .button-quiet { background: transparent; color: #334155; border-color: transparent; } .button-danger { background: #fff; color: #b42318; border-color: #e1aaa4; }
.file-button input { display: none; }
#app-panel { margin-top: 32px; } .toolbar { margin-bottom: 18px; } .toolbar-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.notice { min-height: 20px; margin: 8px 0 14px; font-size: 13px; color: #17603a; } .error { color: #b42318; }
.plugin-list { display: grid; gap: 12px; }
.plugin-card { background: #fff; border: 1px solid #d9dee5; border-radius: 6px; padding: 18px; } .plugin-title { align-items: flex-start; } .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; overflow-wrap: anywhere; }
.badge { display: inline-flex; padding: 4px 8px; border-radius: 999px; color: #334155; background: #e8edf2; font-size: 12px; } .badge-healthy { background: #d9f6e5; color: #146c43; } .badge-error, .badge-incompatible { background: #fde1df; color: #9b1c16; } .badge-starting, .badge-draining { background: #fff0c2; color: #7a4d00; }
.meta { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin: 18px 0 12px; } .meta div { min-width: 0; } dt { color: #687585; font-size: 12px; margin-bottom: 4px; } dd { margin: 0; overflow-wrap: anywhere; font-size: 13px; } .plugin-error { min-height: 18px; margin-bottom: 12px; font-size: 12px; color: #b42318; }
.card-actions { justify-content: flex-start; flex-wrap: wrap; } .empty { padding: 32px; text-align: center; color: #687585; border: 1px dashed #c7d0da; border-radius: 6px; background: #fff; }
.audit-panel { margin-top: 24px; padding: 18px; } .audit-list { display: grid; gap: 1px; } .audit-row { display: grid; grid-template-columns: 1.2fr 1.3fr .8fr 1.7fr; gap: 10px; padding: 9px 0; border-top: 1px solid #edf0f3; font-size: 12px; overflow-wrap: anywhere; }
.config-dialog { width: min(460px, calc(100% - 24px)); padding: 0; border: 0; border-radius: 6px; box-shadow: 0 18px 60px rgb(15 23 42 / 24%); } .config-dialog::backdrop { background: rgb(15 23 42 / 42%); } .config-form { display: grid; gap: 14px; padding: 22px; } .config-form .section-heading { margin-bottom: 4px; } .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
@media (max-width: 640px) { .shell { width: min(100% - 20px, 560px); padding-top: 20px; } .topbar, .toolbar { align-items: flex-start; flex-direction: column; } .top-actions { width: 100%; justify-content: space-between; } .meta { grid-template-columns: 1fr; gap: 9px; } .card-actions .button, .toolbar-actions .button, .file-button { flex: 1 1 130px; } .audit-row { grid-template-columns: 1fr 1fr; } }