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; } }
+16
View File
@@ -0,0 +1,16 @@
CORE_BASE_URL=http://127.0.0.1:8080
PLUGIN_HOST=127.0.0.1
PLUGIN_PORT=8091
# Optional public path when mounted behind a reverse proxy, e.g.
# /extensions/qiu.subscription-admin
PLUGIN_PUBLIC_BASE_PATH=
# Limit the session cookie to the plugin mount path in production.
PLUGIN_COOKIE_PATH=
# Set true only behind HTTPS. Non-loopback listeners fail closed when false.
PLUGIN_COOKIE_SECURE=false
# lax (same-site proxy), strict, or none (cross-site iframe; requires Secure).
PLUGIN_COOKIE_SAMESITE=lax
# Space-separated frame ancestors. Keep 'self' for same-origin proxying.
PLUGIN_FRAME_ANCESTORS='self'
# Set true only when the immediate reverse proxy is trusted and supplies XFF.
PLUGIN_TRUST_PROXY=false
+14
View File
@@ -0,0 +1,14 @@
.PHONY: test build package check
test:
go test ./... -count=1
build:
./build.sh
package:
./package.sh
check: test
node --check ui/app.js
sh -n package.sh
+97
View File
@@ -0,0 +1,97 @@
# Sub2API Subscription Admin Business Plugin V1
这是一个独立运行的管理员只读业务插件,不是现有 `.s2plugin` transport 插件,也不是插件管理后台。它不导入 Sub2API `internal` 包,不连接 Core 数据库,也不修改 Core Go/Vue、迁移、路由或 `.s2plugin` ABI。
生产/集成环境由通用 `plugins/plugin-admin` 控制面安装、启用和升级本插件;本插件不会预装,也不会成为控制面首页。只有健康检查通过并由管理员执行菜单预览/应用后,Core 管理员菜单才会出现“订阅管理”入口。直接运行本目录仅用于本地开发和契约测试。
## 本地启动
```sh
CORE_BASE_URL=http://127.0.0.1:8080 \
PLUGIN_HOST=127.0.0.1 \
PLUGIN_PORT=8091 \
go run .
```
打开 `http://127.0.0.1:8091/admin/`。生产环境应通过 HTTPS 反向代理,并设置 `PLUGIN_COOKIE_SECURE=true`。挂载到子路径时同时设置 `PLUGIN_PUBLIC_BASE_PATH` 和 `PLUGIN_COOKIE_PATH`,例如 `/extensions/qiu.subscription-admin`。
## V1 范围
- Core 管理员账号登录和 Core 2FA;普通账号统一拒绝。
- 插件 HttpOnly、SameSite 会话和写请求 CSRF 校验。
- Core token 只保存在插件服务端内存会话中,不进入浏览器、URL、HTML、LocalStorage、响应或日志。
- 只读套餐、订阅列表、订阅详情和插件操作记录。
- Core access token 失效时最多刷新一次;刷新失败会销毁插件会话。
- 页面刷新会从插件会话恢复,并重新取得短期 CSRF token;服务端不会把 Core token 返回浏览器。
- 登录会读取 Core 公开验证码配置并透传 Turnstile、腾讯、阿里云或 GeeTest 的验证结果;验证码本身仍由 Core 校验。
- 余额购买、续费、撤销、退款和外部支付不在 V1,页面不渲染提交按钮。
## Core API allowlist
插件服务端仅调用这些明确路径:
```text
POST /api/v1/auth/login
POST /api/v1/auth/login/2fa
POST /api/v1/auth/refresh
POST /api/v1/auth/logout
GET /api/v1/auth/me
GET /api/v1/settings/public
GET /api/v1/admin/payment/plans
GET /api/v1/admin/subscriptions
GET /api/v1/admin/subscriptions/{id}
GET /api/v1/admin/users/{id}
GET /api/v1/admin/users/{id}/subscriptions
```
列表请求只接受 `page`、`page_size`、`limit`、`user_id`、`group_id`、`status`、`platform`、`sort_by`、`sort_order` 参数。插件不会代理任意 URL,也不会调用尚不存在的 `/api/v1/plugin-host/*`。
## Core 菜单与反向代理
控制面会依据清单中的菜单声明生成下面的 `custom_menu_items` 项;部署时无需手工写入订阅菜单:
```json
{
"id": "qiu.subscription-admin",
"label": "订阅管理",
"url": "https://CORE_ORIGIN/extensions/qiu.subscription-admin/",
"visibility": "admin",
"sort_order": 200
}
```
Core 自定义页面的 sandbox iframe 不会继承 Core `localStorage` 登录态,因此 V1 首屏显示插件登录页是预期行为;同时提供新窗口入口。不要把 JWT 放进 URL。
## 测试
```sh
go test ./... -count=1
node --check ui/app.js
```
`main_test.go` 覆盖 Core 路径 allowlist、查询参数过滤、管理员角色拒绝、插件 Cookie、Core token 不泄露、会话过期、请求 ID、登录限流和 2FA pending 一次性消费。浏览器验收脚本位于 `test/browser-check.mjs`,可使用本地 Mock Core 验证直连、子路径反代和三种视口。
## 生成可安装包
```sh
./package.sh
```
脚本生成 `dist/qiu.subscription-admin.s2plugin`,包内根文件名为
`manifest.json`,并包含清单声明哈希的 UI 文件。该插件采用外部服务模式:
安装后先独立启动 `subscription-admin`,再在 `plugin-admin` 的配置中填写
`service_url`(插件 loopback 地址)和 `public_url`(反向代理地址),然后执行
启用、健康检查和菜单应用。生产环境必须把签名文件通过
`SIGNATURE_FILE=/path/to/signature.json ./package.sh` 放入包内,并将对应公钥
加入控制面受信发布者配置;未签名包仅限 development + loopback。
## 清单和发布
`business-plugin-manifest.v1.json` 是部署层清单,不由 Core 读取。生产发布应由独立 CI 签名并校验清单、版本、健康路径和兼容的 Core 版本;不要把发布私钥放入仓库或插件包。插件版本独立于 `backend/cmd/server/VERSION`。
## 已知限制
- V1 使用内存会话,服务重启会要求重新登录;多实例部署需将会话存储替换为插件自有 Redis/共享会话服务。
- 现有 Core 自定义 iframe 没有 token handoff,V1 不提供无感 SSO;真正 SSO 需要单独的 V1.1 Core 交接接口。
- Core 当前套餐响应中的 `features` 可能是 JSON 字符串,UI 会兼容字符串和数组。
- Core 开启验证码时,管理员必须先完成对应提供商的挑战并将结果填入登录表单;插件不保存验证码票据。
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env sh
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
mkdir -p "$ROOT/bin"
CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o "$ROOT/bin/subscription-admin" "$ROOT"
@@ -0,0 +1,44 @@
{
"schema_version": 1,
"plugin_id": "qiu.subscription-admin",
"name": "Subscription Admin",
"version": "0.1.1",
"core_api_baseline": "sub2api-0.1.183",
"capabilities": ["subscription.admin.v1"],
"tested_core_versions": ["0.1.183"],
"backend": {
"health_path": "/healthz",
"readiness_path": "/readyz",
"listen_env": "PLUGIN_PORT"
},
"ui": {
"entrypoint": "ui/index.html",
"menu": {
"id": "qiu.subscription-admin",
"label": "订阅管理",
"visibility": "admin",
"sort_order": 200
}
},
"publisher": {
"key_id": "qiu-subscription-admin-dev"
},
"core_api_allowlist": [
"POST /api/v1/auth/login",
"POST /api/v1/auth/login/2fa",
"POST /api/v1/auth/refresh",
"POST /api/v1/auth/logout",
"GET /api/v1/auth/me",
"GET /api/v1/settings/public",
"GET /api/v1/admin/payment/plans",
"GET /api/v1/admin/subscriptions",
"GET /api/v1/admin/subscriptions/{id}",
"GET /api/v1/admin/users/{id}",
"GET /api/v1/admin/users/{id}/subscriptions"
],
"files": {
"ui/index.html": "a189f81675f1bf7820111123221d8056111c86ca104fad2906e961fad6ef4697",
"ui/app.js": "51896358caa769c1fc6353613b92d02bbdd340a24dca567b4f86fcaf1f5f36ca",
"ui/styles.css": "d96dff24fa6f7d96a977f5d8f09986cedb987aad1bb26177b9bf4d7b5982ae54"
}
}
@@ -0,0 +1,12 @@
# Keep the plugin service private on loopback; expose it behind HTTPS.
CORE_ORIGIN {
handle_path /extensions/qiu.subscription-admin/* {
reverse_proxy 127.0.0.1:8091
}
}
# Start the plugin with:
# PLUGIN_PUBLIC_BASE_PATH=/extensions/qiu.subscription-admin
# PLUGIN_COOKIE_PATH=/extensions/qiu.subscription-admin/
# PLUGIN_COOKIE_SECURE=true
# PLUGIN_FRAME_ANCESTORS='self'
@@ -0,0 +1,7 @@
{
"id": "qiu.subscription-admin",
"label": "订阅管理",
"url": "https://CORE_ORIGIN/extensions/qiu.subscription-admin/",
"visibility": "admin",
"sort_order": 200
}
@@ -0,0 +1,17 @@
# Deploy beside the Core reverse proxy. Keep the plugin bound to loopback.
location /extensions/qiu.subscription-admin/ {
proxy_pass http://127.0.0.1:8091/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
}
# Start the plugin with:
# PLUGIN_PUBLIC_BASE_PATH=/extensions/qiu.subscription-admin
# PLUGIN_COOKIE_PATH=/extensions/qiu.subscription-admin/
# PLUGIN_COOKIE_SECURE=true
# PLUGIN_FRAME_ANCESTORS='self'
@@ -0,0 +1,22 @@
[Unit]
Description=Sub2API Subscription Admin business plugin
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=sub2api-plugin
Group=sub2api-plugin
WorkingDirectory=/opt/sub2api/subscription-admin
EnvironmentFile=/etc/sub2api/subscription-admin.env
ExecStart=/opt/sub2api/subscription-admin/bin/subscription-admin
Restart=on-failure
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/sub2api/subscription-admin
[Install]
WantedBy=multi-user.target
+3
View File
@@ -0,0 +1,3 @@
module git.awaioi.com/awaioi/sub2api-add/plugins/subscription-admin
go 1.23
@@ -0,0 +1,204 @@
package manifest
import (
"crypto/ed25519"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"regexp"
"sort"
"strings"
)
var pluginIDPattern = regexp.MustCompile(`^[a-z0-9]+([._-][a-z0-9]+)+$`)
var versionPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$`)
var requiredAllowlist = []string{
"POST /api/v1/auth/login",
"POST /api/v1/auth/login/2fa",
"POST /api/v1/auth/refresh",
"POST /api/v1/auth/logout",
"GET /api/v1/auth/me",
"GET /api/v1/settings/public",
"GET /api/v1/admin/payment/plans",
"GET /api/v1/admin/subscriptions",
"GET /api/v1/admin/subscriptions/{id}",
"GET /api/v1/admin/users/{id}",
"GET /api/v1/admin/users/{id}/subscriptions",
}
type Manifest struct {
SchemaVersion int `json:"schema_version"`
PluginID string `json:"plugin_id"`
Name string `json:"name"`
Version string `json:"version"`
CoreAPIBaseline string `json:"core_api_baseline"`
Capabilities []string `json:"capabilities"`
TestedCoreVersions []string `json:"tested_core_versions"`
Backend struct {
HealthPath string `json:"health_path"`
ReadinessPath string `json:"readiness_path"`
ListenEnv string `json:"listen_env"`
} `json:"backend"`
UI struct {
Entrypoint string `json:"entrypoint"`
Menu struct {
ID string `json:"id"`
Label string `json:"label"`
Visibility string `json:"visibility"`
SortOrder int `json:"sort_order"`
} `json:"menu"`
} `json:"ui"`
Publisher struct {
KeyID string `json:"key_id"`
} `json:"publisher"`
CoreAPIAllowlist []string `json:"core_api_allowlist"`
Files map[string]string `json:"files,omitempty"`
}
type Signature struct {
Algorithm string `json:"algorithm"`
KeyID string `json:"key_id"`
Signature string `json:"signature"`
}
func Load(path string) (Manifest, []byte, error) {
raw, err := os.ReadFile(path)
if err != nil {
return Manifest{}, nil, err
}
var m Manifest
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
if err := dec.Decode(&m); err != nil {
return Manifest{}, nil, fmt.Errorf("decode manifest: %w", err)
}
var trailing any
if err := dec.Decode(&trailing); err != io.EOF {
if err == nil {
return Manifest{}, nil, errors.New("manifest contains trailing JSON")
}
return Manifest{}, nil, fmt.Errorf("decode manifest trailing data: %w", err)
}
if err := Validate(m); err != nil {
return Manifest{}, nil, err
}
return m, raw, nil
}
func Validate(m Manifest) error {
if m.SchemaVersion != 1 {
return fmt.Errorf("schema_version must be 1")
}
if !pluginIDPattern.MatchString(m.PluginID) {
return fmt.Errorf("invalid plugin_id")
}
if strings.TrimSpace(m.Name) == "" || len(m.Name) > 160 {
return fmt.Errorf("name is required and must be at most 160 characters")
}
if !versionPattern.MatchString(strings.TrimPrefix(m.Version, "v")) {
return fmt.Errorf("invalid plugin version")
}
baseline := strings.TrimPrefix(m.CoreAPIBaseline, "sub2api-")
if !versionPattern.MatchString(strings.TrimPrefix(baseline, "v")) {
return fmt.Errorf("invalid core_api_baseline")
}
if len(m.Capabilities) != 1 || m.Capabilities[0] != "subscription.admin.v1" {
return fmt.Errorf("capabilities must contain subscription.admin.v1 only")
}
for _, version := range m.TestedCoreVersions {
if !versionPattern.MatchString(strings.TrimPrefix(version, "v")) {
return fmt.Errorf("invalid tested_core_versions entry")
}
}
if m.Backend.HealthPath != "/healthz" || m.Backend.ReadinessPath != "/readyz" || m.Backend.ListenEnv != "PLUGIN_PORT" {
return fmt.Errorf("backend health_path/listen_env do not match V1 contract")
}
if (m.UI.Entrypoint != "/admin" && m.UI.Entrypoint != "/admin/" && m.UI.Entrypoint != "ui/index.html") || m.UI.Menu.ID != m.PluginID || strings.TrimSpace(m.UI.Menu.Label) == "" || m.UI.Menu.Visibility != "admin" || m.UI.Menu.SortOrder < 0 || m.Publisher.KeyID == "" {
return fmt.Errorf("ui.entrypoint/menu and publisher.key_id are required")
}
if len(m.CoreAPIAllowlist) != len(requiredAllowlist) {
return fmt.Errorf("core_api_allowlist must contain exactly %d entries", len(requiredAllowlist))
}
seen := make(map[string]struct{}, len(m.CoreAPIAllowlist))
for _, entry := range m.CoreAPIAllowlist {
if _, ok := seen[entry]; ok {
return fmt.Errorf("duplicate allowlist entry: %s", entry)
}
seen[entry] = struct{}{}
}
for _, required := range requiredAllowlist {
if _, ok := seen[required]; !ok {
return fmt.Errorf("missing allowlist entry: %s", required)
}
}
for file, hash := range m.Files {
if strings.TrimSpace(file) == "" || strings.HasPrefix(strings.ReplaceAll(file, "\\", "/"), "/") || strings.Contains(strings.ReplaceAll(file, "\\", "/"), "..") {
return fmt.Errorf("invalid file declaration: %s", file)
}
if _, err := hex.DecodeString(hash); err != nil || len(hash) != 64 {
return fmt.Errorf("invalid file hash declaration: %s", file)
}
}
return nil
}
// DeclaredFiles returns file paths in deterministic order for package tooling.
func DeclaredFiles(m Manifest) []string {
files := make([]string, 0, len(m.Files))
for file := range m.Files {
files = append(files, file)
}
sort.Strings(files)
return files
}
func VerifySignature(manifestBytes, signatureBytes, publicKeyBytes []byte) error {
var signature Signature
dec := json.NewDecoder(strings.NewReader(string(signatureBytes)))
dec.DisallowUnknownFields()
if err := dec.Decode(&signature); err != nil {
return fmt.Errorf("decode signature: %w", err)
}
var trailing any
if err := dec.Decode(&trailing); err != io.EOF {
if err == nil {
return errors.New("signature contains trailing JSON")
}
return fmt.Errorf("decode signature trailing data: %w", err)
}
if signature.Algorithm != "ed25519" || signature.KeyID == "" {
return errors.New("signature must use ed25519 and include key_id")
}
publicKey, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(publicKeyBytes)))
if err != nil || len(publicKey) != ed25519.PublicKeySize {
return errors.New("invalid base64 ed25519 public key")
}
sig, err := base64.StdEncoding.DecodeString(signature.Signature)
if err != nil || len(sig) != ed25519.SignatureSize {
return errors.New("invalid base64 ed25519 signature")
}
if !ed25519.Verify(ed25519.PublicKey(publicKey), manifestBytes, sig) {
return errors.New("manifest signature verification failed")
}
return nil
}
func VerifyKeyID(signatureBytes []byte, expectedKeyID string) error {
var signature Signature
if err := json.Unmarshal(signatureBytes, &signature); err != nil {
return fmt.Errorf("decode signature: %w", err)
}
if strings.TrimSpace(expectedKeyID) == "" || signature.KeyID != expectedKeyID {
return errors.New("signature key_id does not match manifest publisher")
}
return nil
}
func RequiredAllowlist() []string {
return append([]string(nil), requiredAllowlist...)
}
@@ -0,0 +1,62 @@
package manifest
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"testing"
)
func validManifest() Manifest {
var m Manifest
m.SchemaVersion = 1
m.PluginID = "qiu.subscription-admin"
m.Name = "Subscription Admin"
m.Version = "0.1.0"
m.CoreAPIBaseline = "sub2api-0.1.183"
m.Capabilities = []string{"subscription.admin.v1"}
m.TestedCoreVersions = []string{"0.1.183"}
m.Backend.HealthPath = "/healthz"
m.Backend.ReadinessPath = "/readyz"
m.Backend.ListenEnv = "PLUGIN_PORT"
m.UI.Entrypoint = "ui/index.html"
m.UI.Menu.ID = m.PluginID
m.UI.Menu.Label = "订阅管理"
m.UI.Menu.Visibility = "admin"
m.UI.Menu.SortOrder = 200
m.Publisher.KeyID = "test-key"
m.CoreAPIAllowlist = RequiredAllowlist()
return m
}
func TestValidateManifest(t *testing.T) {
if err := Validate(validManifest()); err != nil {
t.Fatal(err)
}
m := validManifest()
m.CoreAPIAllowlist = append(m.CoreAPIAllowlist, "GET /api/v1/admin/users")
if err := Validate(m); err == nil {
t.Fatal("expected exact allowlist validation failure")
}
}
func TestVerifySignature(t *testing.T) {
publicKey, privateKey, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
manifestBytes := []byte(`{"schema_version":1}`)
signature := Signature{Algorithm: "ed25519", KeyID: "test-key", Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, manifestBytes))}
signatureBytes, err := json.Marshal(signature)
if err != nil {
t.Fatal(err)
}
publicKeyBytes := []byte(base64.StdEncoding.EncodeToString(publicKey))
if err := VerifySignature(manifestBytes, signatureBytes, publicKeyBytes); err != nil {
t.Fatal(err)
}
manifestBytes[0] = '{'
if err := VerifySignature([]byte(`{"schema_version":2}`), signatureBytes, publicKeyBytes); err == nil {
t.Fatal("expected tamper failure")
}
}
File diff suppressed because it is too large Load Diff
+369
View File
@@ -0,0 +1,369 @@
package main
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func testCoreClient(t *testing.T, handler http.Handler) *coreClient {
t.Helper()
ts := httptest.NewServer(handler)
t.Cleanup(ts.Close)
c, err := newCoreClient(ts.URL)
if err != nil {
t.Fatal(err)
}
return c
}
func TestCoreClientAllowlistAndRequestID(t *testing.T) {
var gotPath, gotAuth, gotRequestID string
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath, gotAuth, gotRequestID = r.URL.RequestURI(), r.Header.Get("Authorization"), r.Header.Get("X-Request-Id")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"role":"admin"}}`))
}))
if _, err := c.read(context.Background(), "/api/v1/admin/subscriptions?page=1&evil=ignored", "CORE-TOKEN"); err != nil {
t.Fatal(err)
}
if gotPath != "/api/v1/admin/subscriptions?page=1" {
t.Fatalf("path=%q", gotPath)
}
if gotAuth != "Bearer CORE-TOKEN" || gotRequestID == "" {
t.Fatalf("headers auth=%q request_id=%q", gotAuth, gotRequestID)
}
if _, err := c.read(context.Background(), "/api/v1/admin/payment/plans/1", "TOKEN"); err == nil {
t.Fatal("unexpected allowlist success")
}
}
func TestCoreReadQueryIsSanitized(t *testing.T) {
var gotPath string
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.RequestURI()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":[]}`))
}))
if _, err := c.read(context.Background(), "/api/v1/admin/subscriptions?page=1&evil=ignored", "TOKEN"); err != nil {
t.Fatal(err)
}
if gotPath != "/api/v1/admin/subscriptions?page=1" {
t.Fatalf("sanitized path=%q", gotPath)
}
}
func TestNewCoreClientRejectsNonAbsoluteOrQueryURL(t *testing.T) {
for _, base := range []string{"", "/api", "ftp://core", "https://core.test/?token=secret", "http://core.test", "https://user:pass@core.test"} {
if _, err := newCoreClient(base); err == nil {
t.Fatalf("expected invalid Core URL: %q", base)
}
}
for _, base := range []string{"http://127.0.0.1:8080", "http://[::1]:8080", "http://localhost:8080"} {
if _, err := newCoreClient(base); err != nil {
t.Fatalf("expected loopback URL to be accepted: %q: %v", base, err)
}
}
}
func TestCoreClientRejectsNonzeroEnvelopeCode(t *testing.T) {
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":422,"message":"bad","data":{}}`))
}))
if _, err := c.read(context.Background(), "/api/v1/admin/subscriptions", "TOKEN"); err == nil {
t.Fatal("expected nonzero Core envelope to fail")
}
}
func TestLoginRequiresAdminAndDoesNotReturnCoreToken(t *testing.T) {
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/auth/login":
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"access_token":"CORE-TOKEN","refresh_token":"CORE-REFRESH"}}`))
case "/api/v1/auth/me":
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":7,"role":"user","email":"user@example.com"}}`))
case "/api/v1/auth/logout":
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
default:
t.Errorf("unexpected Core path %s", r.URL.Path)
}
}))
a := newApp(c, false)
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"email":"user@example.com","password":"password"}`))
rec := httptest.NewRecorder()
a.login(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "CORE-TOKEN") || strings.Contains(rec.Body.String(), "CORE-REFRESH") {
t.Fatalf("core token leaked: %s", rec.Body.String())
}
}
func TestLoginAndReadProxyUsePluginCookie(t *testing.T) {
var readAuth string
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/auth/login":
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"access_token":"CORE-TOKEN","refresh_token":"CORE-REFRESH"}}`))
case "/api/v1/auth/me":
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":1,"role":"admin","email":"admin@example.com"}}`))
case "/api/v1/admin/subscriptions":
readAuth = r.Header.Get("Authorization")
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"items":[],"total":0,"page":1,"page_size":20,"pages":1}}`))
default:
t.Errorf("unexpected Core path %s", r.URL.Path)
}
}))
a := newApp(c, false)
loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"email":"admin@example.com","password":"password"}`))
loginRec := httptest.NewRecorder()
a.login(loginRec, loginReq)
if loginRec.Code != http.StatusOK || strings.Contains(loginRec.Body.String(), "CORE-TOKEN") {
t.Fatalf("login status/body: %d %s", loginRec.Code, loginRec.Body.String())
}
cookie := loginRec.Result().Cookies()[0]
readReq := httptest.NewRequest(http.MethodGet, "/api/subscriptions?page=1", nil)
readReq.AddCookie(cookie)
readRec := httptest.NewRecorder()
a.readProxy("/api/v1/admin/subscriptions")(readRec, readReq)
if readRec.Code != http.StatusOK || readAuth != "Bearer CORE-TOKEN" {
t.Fatalf("read status=%d auth=%q body=%s", readRec.Code, readAuth, readRec.Body.String())
}
}
func TestReadProxyStripsSensitiveCoreFields(t *testing.T) {
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/auth/me":
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":1,"role":"admin"}}`))
case "/api/v1/admin/subscriptions":
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"access_token":"LEAK","items":[{"refresh_token":"LEAK2","id":1}]}}`))
default:
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
}
}))
a := newApp(c, false)
a.sessions["sid"] = session{accessToken: "TOKEN", csrfToken: "CSRF", createdAt: time.Now(), lastSeen: time.Now(), user: map[string]any{"id": 1}}
req := httptest.NewRequest(http.MethodGet, "/api/subscriptions", nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
rec := httptest.NewRecorder()
a.readProxy("/api/v1/admin/subscriptions")(rec, req)
if strings.Contains(rec.Body.String(), "LEAK") || strings.Contains(rec.Body.String(), "refresh_token") {
t.Fatalf("sensitive field leaked: %s", rec.Body.String())
}
}
func TestCoreRevocationDestroysPluginSession(t *testing.T) {
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api/v1/auth/me" {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"code":401,"message":"revoked"}`))
return
}
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
}))
a := newApp(c, false)
now := time.Now()
a.sessions["sid"] = session{accessToken: "TOKEN", csrfToken: "CSRF", createdAt: now, lastSeen: now, user: map[string]any{"id": 1}}
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
rec := httptest.NewRecorder()
a.me(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if _, ok := a.sessions["sid"]; ok {
t.Fatal("revoked Core session remained in plugin store")
}
}
func TestLogoutRevokesCoreRefreshToken(t *testing.T) {
var logoutCalls int32
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api/v1/auth/logout" {
atomic.AddInt32(&logoutCalls, 1)
var body map[string]string
_ = json.NewDecoder(r.Body).Decode(&body)
if body["refresh_token"] != "REFRESH" {
t.Errorf("refresh token=%q", body["refresh_token"])
}
}
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
}))
a := newApp(c, false)
a.sessions["sid"] = session{accessToken: "TOKEN", refreshToken: "REFRESH", csrfToken: "CSRF", createdAt: time.Now(), lastSeen: time.Now(), user: map[string]any{"id": 1}}
a.sessionLocks["sid"] = &sync.Mutex{}
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
req.Header.Set("X-CSRF-Token", "CSRF")
rec := httptest.NewRecorder()
a.logout(rec, req)
if rec.Code != http.StatusOK || atomic.LoadInt32(&logoutCalls) != 1 {
t.Fatalf("status=%d logout_calls=%d body=%s", rec.Code, logoutCalls, rec.Body.String())
}
}
func TestReadProxyRefreshesAtMostOncePerRequest(t *testing.T) {
var refreshCalls int32
var meCalls int32
var readCalls int32
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/auth/me":
call := atomic.AddInt32(&meCalls, 1)
if call == 1 {
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":1,"role":"admin"}}`))
} else {
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":1,"role":"admin"}}`))
}
case "/api/v1/admin/subscriptions":
call := atomic.AddInt32(&readCalls, 1)
if call == 1 {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"code":401,"message":"expired"}`))
} else {
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"items":[]}}`))
}
case "/api/v1/auth/refresh":
atomic.AddInt32(&refreshCalls, 1)
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"access_token":"NEW","refresh_token":"NEW-REFRESH"}}`))
default:
t.Errorf("unexpected Core path %s", r.URL.Path)
}
}))
a := newApp(c, false)
now := time.Now()
a.sessions["sid"] = session{accessToken: "TOKEN", refreshToken: "REFRESH", csrfToken: "CSRF", createdAt: now, lastSeen: now, user: map[string]any{"id": 1}}
a.sessionLocks["sid"] = &sync.Mutex{}
req := httptest.NewRequest(http.MethodGet, "/api/subscriptions", nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
rec := httptest.NewRecorder()
a.readProxy("/api/v1/admin/subscriptions")(rec, req)
if rec.Code != http.StatusOK || atomic.LoadInt32(&refreshCalls) != 1 {
t.Fatalf("status=%d refresh_calls=%d body=%s", rec.Code, refreshCalls, rec.Body.String())
}
}
func TestSessionExpiry(t *testing.T) {
now := time.Now()
a := newApp(nil, false)
a.clock = func() time.Time { return now }
a.sessions["sid"] = session{accessToken: "TOKEN", csrfToken: "CSRF", createdAt: now, lastSeen: now.Add(-sessionTTL - time.Second), user: map[string]any{"id": 1}}
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
rec := httptest.NewRecorder()
a.me(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestTwoFactorPendingTokenIsSingleUse(t *testing.T) {
now := time.Now()
a := newApp(nil, false)
a.clock = func() time.Time { return now }
a.pending["pending"] = pendingLogin{tempToken: "CORE-TEMP", expires: now.Add(time.Minute)}
first := a.pending["pending"]
delete(a.pending, "pending")
if _, ok := a.pending["pending"]; ok || first.tempToken != "CORE-TEMP" {
t.Fatal("pending token was not consumed")
}
}
func TestAllowedReadPathRejectsTraversalAndUnknownRoutes(t *testing.T) {
for _, path := range []string{
"/api/v1/admin/subscriptions/1/progress",
"/api/v1/admin/users/1/subscriptions/extra",
"/api/v1/admin/payment/plans/1",
"/api/v1/admin/../users",
} {
if allowedReadPath(path) {
t.Fatalf("unexpected allowlist match: %s", path)
}
}
}
func TestRoutesProtectReadOnlyEndpointsAndSetSecurityHeaders(t *testing.T) {
a := newApp(nil, false)
server := httptest.NewServer(a.routes())
t.Cleanup(server.Close)
response, err := server.Client().Get(server.URL + "/api/plans")
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusUnauthorized {
t.Fatalf("status=%d", response.StatusCode)
}
if response.Header.Get("Content-Security-Policy") == "" || response.Header.Get("X-Content-Type-Options") != "nosniff" {
t.Fatalf("security headers missing: %#v", response.Header)
}
}
func TestRoutesPropagateRequestIDAndBootstrapSession(t *testing.T) {
var coreRequestID string
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
coreRequestID = r.Header.Get(requestIDHeader)
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/auth/login":
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"access_token":"A","refresh_token":"R"}}`))
case "/api/v1/auth/me":
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":1,"role":"admin","email":"a@example.com"}}`))
case "/api/v1/auth/logout":
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
default:
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
}
}))
a := newApp(c, false)
server := httptest.NewServer(a.routes())
t.Cleanup(server.Close)
request, _ := http.NewRequest(http.MethodPost, server.URL+"/login", strings.NewReader(`{"email":"a@example.com","password":"password"}`))
request.Header.Set(requestIDHeader, "client-request-123")
request.Header.Set("Content-Type", "application/json")
response, err := server.Client().Do(request)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusOK || response.Header.Get(requestIDHeader) != "client-request-123" || coreRequestID != "client-request-123" {
t.Fatalf("status=%d response_id=%q core_id=%q", response.StatusCode, response.Header.Get(requestIDHeader), coreRequestID)
}
cookies := response.Cookies()
if len(cookies) != 1 || !cookies[0].HttpOnly || cookies[0].SameSite != http.SameSiteLaxMode {
t.Fatalf("cookie=%#v", cookies)
}
bootstrap, _ := http.NewRequest(http.MethodGet, server.URL+"/api/me", nil)
bootstrap.AddCookie(cookies[0])
bootstrapResponse, err := server.Client().Do(bootstrap)
if err != nil {
t.Fatal(err)
}
if bootstrapResponse.StatusCode != http.StatusOK || !strings.Contains(readBody(t, bootstrapResponse), "csrf_token") {
t.Fatalf("bootstrap status=%d", bootstrapResponse.StatusCode)
}
}
func readBody(t *testing.T, response *http.Response) string {
t.Helper()
defer response.Body.Close()
data, err := io.ReadAll(response.Body)
if err != nil {
t.Fatal(err)
}
return string(data)
}
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env sh
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
OUT=${OUT:-"$ROOT/dist/qiu.subscription-admin.s2plugin"}
command -v jq >/dev/null 2>&1 || { printf '%s\n' 'jq is required to build a package' >&2; exit 2; }
TMP=$(mktemp -d)
cleanup() {
rm -rf "$TMP"
}
trap cleanup EXIT INT TERM
mkdir -p "$TMP/ui" "$(dirname -- "$OUT")"
cp "$ROOT/ui/index.html" "$ROOT/ui/app.js" "$ROOT/ui/styles.css" "$TMP/ui/"
# Recompute declared hashes at package time so a UI change cannot produce an
# archive that the control plane rejects. The repository descriptor remains
# the human-readable source contract.
INDEX_HASH=$(shasum -a 256 "$ROOT/ui/index.html" | awk '{print $1}')
APP_HASH=$(shasum -a 256 "$ROOT/ui/app.js" | awk '{print $1}')
STYLES_HASH=$(shasum -a 256 "$ROOT/ui/styles.css" | awk '{print $1}')
jq --arg index_hash "$INDEX_HASH" --arg app_hash "$APP_HASH" --arg styles_hash "$STYLES_HASH" \
'.files["ui/index.html"]=$index_hash | .files["ui/app.js"]=$app_hash | .files["ui/styles.css"]=$styles_hash' \
"$ROOT/business-plugin-manifest.v1.json" > "$TMP/manifest.json"
if [ -n "${SIGNATURE_FILE:-}" ]; then
cp "$SIGNATURE_FILE" "$TMP/signature.json"
fi
OUT_DIR=$(CDPATH= cd -- "$(dirname -- "$OUT")" && pwd)
OUT_PATH="$OUT_DIR/$(basename -- "$OUT")"
(cd "$TMP" && zip -q -r "$OUT_PATH" manifest.json ui)
printf '%s\n' "$OUT_PATH"
@@ -0,0 +1,43 @@
import { chromium } from 'playwright'
import fs from 'node:fs/promises'
import path from 'node:path'
const origin = process.env.PLUGIN_BROWSER_ORIGIN || 'http://127.0.0.1:18081'
const entry = `${origin}/extensions/qiu.subscription-admin/admin/`
const outputDir = process.env.PLUGIN_SCREENSHOT_DIR || '.playwright-cli/subscription-admin'
await fs.mkdir(path.resolve(outputDir), { recursive: true })
const browser = await chromium.launch({ headless: true })
try {
for (const width of [425, 900, 1440]) {
const page = await browser.newPage({ viewport: { width, height: 900 }, deviceScaleFactor: 1 })
const responseLeaks = []
page.on('response', async (response) => {
if (!response.headers()['content-type']?.includes('application/json')) return
try {
const body = await response.text()
if (/access_token|refresh_token|admin[_-]?api[_-]?key|password|client_secret/i.test(body)) responseLeaks.push(response.url())
} catch (_) {}
})
await page.goto(entry, { waitUntil: 'domcontentloaded' })
await page.getByLabel('管理员邮箱').fill('admin@example.com')
await page.getByLabel('密码').fill('password')
await page.getByRole('button', { name: '登录' }).click()
await page.locator('#app-view').waitFor({ state: 'visible' })
await page.waitForTimeout(50)
if (responseLeaks.length) throw new Error(`sensitive response field exposed at ${width}px: ${responseLeaks.join(', ')}`)
await page.getByRole('button', { name: '用户订阅' }).click()
await page.locator('#subscriptions-list table').waitFor()
await page.getByRole('button', { name: '下一页' }).click()
await page.getByRole('button', { name: '概览' }).click()
await page.reload({ waitUntil: 'domcontentloaded' })
await page.locator('#app-view').waitFor({ state: 'visible' })
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)
if (overflow) throw new Error(`horizontal overflow at ${width}px`)
await page.screenshot({ path: path.join(outputDir, `subscription-admin-${width}.png`), fullPage: true })
await page.close()
}
} finally {
await browser.close()
}
@@ -0,0 +1,80 @@
import http from 'node:http'
const port = Number(process.env.MOCK_CORE_PORT || 18080)
const token = process.env.MOCK_ACCESS_TOKEN || 'MOCK-ACCESS'
const refresh = process.env.MOCK_REFRESH_TOKEN || 'MOCK-REFRESH'
const users = new Map([
['admin@example.com', { id: 1, role: 'admin', email: 'admin@example.com', username: 'admin' }],
['user@example.com', { id: 2, role: 'user', email: 'user@example.com', username: 'user' }]
])
function json(res, status, body) {
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
res.end(JSON.stringify(body))
}
async function body(req) {
const chunks = []
for await (const chunk of req) chunks.push(chunk)
return chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {}
}
function authorized(req) {
return req.headers.authorization === `Bearer ${token}` || req.headers.authorization === `Bearer NEW-MOCK-ACCESS`
}
function subscriptions(page = 1, pageSize = 50) {
const all = Array.from({ length: 57 }, (_, index) => ({
id: index + 1,
user_id: 1,
plan_id: 10 + (index % 2),
plan_name: index % 2 ? '专业版' : '基础版',
status: 'active',
starts_at: '2026-08-01T00:00:00Z',
expires_at: '2026-09-01T00:00:00Z',
cycle_usage_usd: index / 100,
cycle_quota_usd: 100,
user: { id: 1, username: 'admin', email: 'admin@example.com', balance: 123.45 }
}))
const start = (page - 1) * pageSize
return { items: all.slice(start, start + pageSize), total: all.length, page, page_size: pageSize, pages: Math.ceil(all.length / pageSize) }
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`)
const path = url.pathname
if (req.method === 'POST' && path === '/api/v1/auth/login') {
const input = await body(req)
const user = users.get(input.email)
if (!user || input.password !== 'password') return json(res, 401, { code: 401, message: 'invalid credentials' })
return json(res, 200, { code: 0, message: 'success', data: { access_token: token, refresh_token: refresh, user } })
}
if (req.method === 'POST' && path === '/api/v1/auth/refresh') {
const input = await body(req)
if (input.refresh_token !== refresh && input.refresh_token !== 'NEW-MOCK-REFRESH') return json(res, 401, { code: 401, message: 'expired' })
return json(res, 200, { code: 0, message: 'success', data: { access_token: 'NEW-MOCK-ACCESS', refresh_token: 'NEW-MOCK-REFRESH' } })
}
if (req.method === 'POST' && path === '/api/v1/auth/logout') return json(res, 200, { code: 0, message: 'success', data: {} })
if (req.method === 'GET' && path === '/api/v1/settings/public') return json(res, 200, { code: 0, message: 'success', data: { turnstile_enabled: false, geetest_captcha_enabled: false, tencent_captcha_enabled: false, aliyun_captcha_enabled: false } })
if (!authorized(req)) return json(res, 401, { code: 401, message: 'unauthorized' })
if (req.method === 'GET' && path === '/api/v1/auth/me') return json(res, 200, { code: 0, message: 'success', data: users.get('admin@example.com') })
if (req.method === 'GET' && path === '/api/v1/admin/payment/plans') {
return json(res, 200, { code: 0, message: 'success', data: [{ id: 10, name: '基础版', price: 9.9, currency: 'USD', validity_days: 30, validity_unit: 'day', for_sale: true, included_groups: [] }, { id: 11, name: '专业版', price: 19.9, currency: 'USD', validity_days: 30, validity_unit: 'day', for_sale: true, included_groups: [] }] })
}
if (req.method === 'GET' && path === '/api/v1/admin/subscriptions') {
const page = Number(url.searchParams.get('page') || 1)
const pageSize = Number(url.searchParams.get('page_size') || 50)
return json(res, 200, { code: 0, message: 'success', data: subscriptions(page, pageSize) })
}
const subscriptionMatch = path.match(/^\/api\/v1\/admin\/subscriptions\/(\d+)$/)
if (req.method === 'GET' && subscriptionMatch) return json(res, 200, { code: 0, message: 'success', data: subscriptions(1, 1).items[0] })
const userSubscriptionsMatch = path.match(/^\/api\/v1\/admin\/users\/(\d+)\/subscriptions$/)
if (req.method === 'GET' && userSubscriptionsMatch) return json(res, 200, { code: 0, message: 'success', data: subscriptions(1, 2).items })
const userMatch = path.match(/^\/api\/v1\/admin\/users\/(\d+)$/)
if (req.method === 'GET' && userMatch) return json(res, 200, { code: 0, message: 'success', data: { id: Number(userMatch[1]), username: 'admin', email: 'admin@example.com', balance: 123.45, frozen_balance: 0 } })
return json(res, 404, { code: 404, message: 'not found' })
})
server.listen(port, '127.0.0.1', () => {
process.stdout.write(`mock core listening on 127.0.0.1:${port}\n`)
})
@@ -0,0 +1,27 @@
import http from 'node:http'
const prefix = '/extensions/qiu.subscription-admin'
const targetPort = Number(process.env.PLUGIN_TARGET_PORT || 18082)
const port = Number(process.env.MOCK_PROXY_PORT || 18081)
const server = http.createServer((req, res) => {
if (!req.url.startsWith(prefix)) {
res.writeHead(404)
res.end('not found')
return
}
const forwardedPath = req.url.slice(prefix.length) || '/'
const proxy = http.request({ hostname: '127.0.0.1', port: targetPort, method: req.method, path: forwardedPath, headers: { ...req.headers, host: `127.0.0.1:${targetPort}` } }, (upstream) => {
const headers = { ...upstream.headers }
if (headers.location && headers.location.startsWith('/admin/')) headers.location = `${prefix}${headers.location}`
res.writeHead(upstream.statusCode || 502, headers)
upstream.pipe(res)
})
proxy.on('error', () => {
if (!res.headersSent) res.writeHead(502)
res.end('proxy error')
})
req.pipe(proxy)
})
server.listen(port, '127.0.0.1', () => process.stdout.write(`mock proxy listening on 127.0.0.1:${port}\n`))
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env sh
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cleanup() {
kill "${PROXY_PID:-}" "${PLUGIN_PID:-}" "${CORE_PID:-}" 2>/dev/null || true
}
trap cleanup EXIT INT TERM
MOCK_CORE_PORT=18080 node "$ROOT/test/mock-core.mjs" >/tmp/subscription-admin-mock-core.log 2>&1 & CORE_PID=$!
CORE_BASE_URL=http://127.0.0.1:18080 \
PLUGIN_HOST=127.0.0.1 \
PLUGIN_PORT=18082 \
PLUGIN_PUBLIC_BASE_PATH=/extensions/qiu.subscription-admin \
PLUGIN_COOKIE_PATH=/extensions/qiu.subscription-admin/ \
PLUGIN_COOKIE_SECURE=false \
go run "$ROOT" >/tmp/subscription-admin-plugin.log 2>&1 & PLUGIN_PID=$!
PLUGIN_TARGET_PORT=18082 MOCK_PROXY_PORT=18081 node "$ROOT/test/mock-proxy.mjs" >/tmp/subscription-admin-mock-proxy.log 2>&1 & PROXY_PID=$!
sleep 2
PLUGIN_BROWSER_ORIGIN=http://127.0.0.1:18081 PLUGIN_SCREENSHOT_DIR="$ROOT/.screenshots/subscription-admin" node "$ROOT/test/browser-check.mjs"
@@ -0,0 +1,45 @@
package main
import (
"flag"
"fmt"
"os"
"git.awaioi.com/awaioi/sub2api-add/plugins/subscription-admin/internal/manifest"
)
func main() {
manifestPath := flag.String("manifest", "business-plugin-manifest.v1.json", "manifest path")
signaturePath := flag.String("signature", "", "optional detached signature path")
publicKeyPath := flag.String("public-key", "", "base64 Ed25519 public key file")
flag.Parse()
m, raw, err := manifest.Load(*manifestPath)
if err != nil {
fatal(err)
}
if (*signaturePath == "") != (*publicKeyPath == "") {
fatal(fmt.Errorf("-signature and -public-key must be provided together"))
}
if *signaturePath != "" {
signature, err := os.ReadFile(*signaturePath)
if err != nil {
fatal(err)
}
publicKey, err := os.ReadFile(*publicKeyPath)
if err != nil {
fatal(err)
}
if err := manifest.VerifyKeyID(signature, m.Publisher.KeyID); err != nil {
fatal(err)
}
if err := manifest.VerifySignature(raw, signature, publicKey); err != nil {
fatal(err)
}
}
fmt.Printf("manifest valid: %s\n", *manifestPath)
}
func fatal(err error) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
+170
View File
@@ -0,0 +1,170 @@
(() => {
'use strict'
const state = {
csrf: '',
plans: [],
subscriptions: null,
subscriptionPage: 1,
subscriptionPageSize: 50,
captcha: { enabled: false, provider: '' }
}
const $ = (selector) => document.querySelector(selector)
const loginView = $('#login-view')
const appView = $('#app-view')
const loginForm = $('#login-form')
const basePath = (document.body.dataset.basePath || '').replace(/\/$/, '')
const route = (path) => `${basePath}${path}`
function showError(message, target = $('#app-error')) {
target.textContent = message || '请求失败'
target.hidden = !message
}
function clearError(target = $('#app-error')) { target.textContent = ''; target.hidden = true }
async function request(path, options = {}) {
const headers = new Headers(options.headers || {})
headers.set('Accept', 'application/json')
if (options.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json')
if (state.csrf && options.method && options.method !== 'GET') headers.set('X-CSRF-Token', state.csrf)
const response = await fetch(route(path), { ...options, headers, credentials: 'same-origin' })
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
if (response.status === 401) { state.csrf = ''; loginView.hidden = false; appView.hidden = true }
throw new Error(payload.error || '请求失败')
}
return payload
}
function unwrap(payload) { return payload && payload.code === 0 && Object.prototype.hasOwnProperty.call(payload, 'data') ? payload.data : payload }
function formatNumber(value) {
if (value === null || value === undefined || value === '') return '-'
const number = Number(value)
return Number.isFinite(number) ? number.toLocaleString('zh-CN', { maximumFractionDigits: 6 }) : String(value)
}
function formatDate(value) {
if (!value) return '-'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('zh-CN', { dateStyle: 'medium', timeStyle: 'short' })
}
function escapeHTML(value) { return String(value ?? '').replace(/[&<>'"]/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[character])) }
function statusLabel(value) { const safe = String(value || 'unknown'); return `<span class="pill pill-${safe.toLowerCase()}">${escapeHTML(safe)}</span>` }
function setView(name) {
document.querySelectorAll('.tab').forEach((button) => button.classList.toggle('active', button.dataset.view === name))
document.querySelectorAll('.view').forEach((view) => { view.hidden = view.id !== `view-${name}` })
if (name === 'plans' && !state.plans.length) loadPlans()
if (name === 'subscriptions' && !state.subscriptions) loadSubscriptions()
if (name === 'audit') loadAudit()
}
async function login(event) {
event.preventDefault(); clearError($('#login-error'))
const form = new FormData(loginForm); const button = loginForm.querySelector('button[type="submit"]'); button.disabled = true
try {
const captchaToken = String(form.get('captcha_token') || '').trim()
if (state.captcha.enabled && !captchaToken) throw new Error('请先完成安全验证并填写验证结果')
let payload = await request('/login', { method: 'POST', body: JSON.stringify({
email: form.get('email'),
password: form.get('password'),
turnstile_token: captchaToken || undefined,
tencent_captcha_ticket: state.captcha.provider === 'tencent' ? captchaToken || undefined : undefined,
tencent_captcha_randstr: state.captcha.provider === 'tencent' ? String(form.get('captcha_randstr') || '').trim() || undefined : undefined
}) })
if (payload.requires_2fa) {
const code = window.prompt('请输入管理员 2FA 验证码')
if (!code) throw new Error('需要 2FA 验证码')
payload = await request('/login/2fa', { method: 'POST', body: JSON.stringify({ pending_token: payload.pending_token, totp_code: code }) })
}
if (!payload.ok || !payload.csrf_token) throw new Error('登录响应无效')
state.csrf = payload.csrf_token; loginView.hidden = true; appView.hidden = false
const user = payload.user || {}; $('#operator').textContent = user.email || user.username || `管理员 #${user.id || '-'}`
await Promise.all([loadStatus(), loadOverview()])
} catch (error) { showError(error.message, $('#login-error')) } finally { button.disabled = false }
}
async function loadPlans() {
try { const payload = unwrap(await request('/api/plans')); state.plans = Array.isArray(payload) ? payload : []; $('#plan-count').textContent = formatNumber(state.plans.length); renderPlans(); return true }
catch (error) { showError(error.message); return false }
}
async function loadSubscriptions() {
try {
const params = new URLSearchParams(); const form = new FormData($('#subscription-filter'))
const userID = String(form.get('user_id') || '').trim(); const status = String(form.get('status') || '')
if (userID && !/^[1-9][0-9]*$/.test(userID)) throw new Error('用户 ID 必须是正整数')
let endpoint = '/api/subscriptions'
if (userID && !status) endpoint = `/api/users/${encodeURIComponent(userID)}/subscriptions`
if (endpoint === '/api/subscriptions') { if (userID) params.set('user_id', userID); if (status) params.set('status', status); params.set('page', String(state.subscriptionPage)); params.set('page_size', String(state.subscriptionPageSize)) }
const payload = unwrap(await request(`${endpoint}${params.toString() ? `?${params.toString()}` : ''}`)) || {}; state.subscriptions = payload
$('#subscription-count').textContent = formatNumber(Array.isArray(payload) ? payload.length : (payload.total ?? payload.items?.length ?? 0)); renderSubscriptions(payload); return true
} catch (error) { showError(error.message); return false }
}
async function loadHealth() { try { const response = await fetch(route('/healthz'), { credentials: 'same-origin', headers: { Accept: 'application/json' } }); return response.ok } catch { return false } }
async function loadOverview() {
clearError(); $('#sync-time').textContent = '同步中'
const results = await Promise.all([loadHealth(), loadPlans(), loadSubscriptions()])
const healthy = results.every(Boolean); const degraded = results.some(Boolean)
setCoreStatus(healthy ? 'ok' : degraded ? 'degraded' : 'bad')
$('#sync-time').textContent = degraded ? `最近同步:${formatDate(new Date().toISOString())}` : '同步失败'
}
async function loadAudit() { try { const payload = await request('/api/audit'); renderAudit(payload.items || []) } catch (error) { showError(error.message) } }
async function loadStatus() {
try { const payload = await request('/api/status'); $('#credential-status').textContent = payload.credential_state === 'server_managed' ? '服务端托管(不回显)' : '不可用' }
catch { $('#credential-status').textContent = '不可用' }
}
async function loadCaptchaConfig() {
try {
const payload = await request('/api/captcha-config'); state.captcha = payload
const panel = $('#captcha-panel')
if (!payload.enabled) { panel.hidden = true; return }
panel.hidden = false; $('#captcha-label').textContent = `${payload.provider || '安全'}验证`
const descriptions = { geetest: 'Core 已启用 GeeTest。请在官方验证组件完成挑战后,将返回的 JSON 验证结果粘贴到此处。', turnstile: 'Core 已启用 Cloudflare Turnstile。请完成挑战后填写返回的验证结果。', tencent: 'Core 已启用腾讯验证码。请填写 ticket,并在下方填写 randstr。', aliyun: 'Core 已启用阿里云验证码。请填写 captchaVerifyParam。' }
$('#captcha-help').textContent = descriptions[payload.provider] || '请完成 Core 配置的验证码后填写验证结果。'
$('#captcha-randstr-row').hidden = payload.provider !== 'tencent'
} catch { $('#captcha-panel').hidden = true }
}
async function loadBalance(userID) {
if (!/^[1-9][0-9]*$/.test(userID)) throw new Error('用户 ID 必须是正整数')
const payload = unwrap(await request(`/api/users/${encodeURIComponent(userID)}`)) || {}; $('#user-balance').textContent = formatNumber(payload.balance)
}
async function bootstrap() {
try {
const payload = await request('/api/me'); state.csrf = payload.csrf_token || ''
if (!state.csrf || !payload.user) throw new Error('session unavailable')
loginView.hidden = true; appView.hidden = false
const user = payload.user; $('#operator').textContent = user.email || user.username || `管理员 #${user.id || '-'}`
await Promise.all([loadCaptchaConfig(), loadStatus(), loadOverview()])
} catch { loginView.hidden = false; appView.hidden = true; await loadCaptchaConfig() }
}
function setCoreStatus(stateName) { const element = $('#core-status'); const labels = { ok: '已连接', degraded: '部分可用', bad: '不可用' }; element.textContent = labels[stateName] || '检查中'; element.className = `status ${stateName === 'ok' ? 'ok' : stateName === 'bad' ? 'bad' : ''}` }
function parseFeatures(features) { if (!features) return []; if (Array.isArray(features)) return features; if (typeof features !== 'string') return []; try { const parsed = JSON.parse(features); return Array.isArray(parsed) ? parsed : [] } catch { return features.split(/[,\n]/).map((item) => item.trim()).filter(Boolean) } }
function renderPlans() {
const container = $('#plans-list'); if (!state.plans.length) { container.innerHTML = '<p class="empty">暂无套餐数据</p>'; return }
container.innerHTML = state.plans.map((plan) => {
const groups = (plan.included_groups || []).map((group) => `<span class="tag">${escapeHTML(group.name || group.id)}</span>`).join('') || '<span class="muted">未返回分组</span>'
const features = parseFeatures(plan.features)
return `<article class="plan-card"><div class="card-heading"><div><h3>${escapeHTML(plan.name || plan.product_name || `套餐 #${plan.id}`)}</h3><p class="muted">${escapeHTML(plan.description || '')}</p></div>${plan.for_sale ? '<span class="pill pill-active">可售</span>' : '<span class="pill">下架</span>'}</div><div class="plan-price">${formatNumber(plan.price)} <small>${escapeHTML(plan.currency || '')}</small></div><dl class="facts"><div><dt>有效期</dt><dd>${formatNumber(plan.validity_days)} ${escapeHTML(plan.validity_unit || '天')}</dd></div><div><dt>周期额度</dt><dd>${formatNumber(plan.cycle_quota_usd)}</dd></div><div><dt>总额度</dt><dd>${formatNumber(plan.total_quota_usd)}</dd></div><div><dt>实例上限</dt><dd>${formatNumber(plan.max_subscriptions_per_user)}</dd></div></dl><div class="tags">${groups}</div>${features.length ? `<ul class="feature-list">${features.map((feature) => `<li>${escapeHTML(feature)}</li>`).join('')}</ul>` : ''}</article>`
}).join('')
}
function renderSubscriptions(payload) {
const container = $('#subscriptions-list'); const items = Array.isArray(payload) ? payload : (payload.items || []); const pagination = $('#subscription-pagination')
if (Array.isArray(payload) || !payload.pages) pagination.hidden = true
else { pagination.hidden = false; $('#subscription-page-info').textContent = `第 ${payload.page || state.subscriptionPage} / ${payload.pages} 页,共 ${formatNumber(payload.total)} 条`; $('#subscription-prev').disabled = (payload.page || state.subscriptionPage) <= 1; $('#subscription-next').disabled = (payload.page || state.subscriptionPage) >= payload.pages }
if (!items.length) { container.innerHTML = '<p class="empty">暂无订阅数据</p>'; return }
container.innerHTML = `<table><thead><tr><th>订阅实例</th><th>用户</th><th>套餐</th><th>状态</th><th>有效期</th><th>周期用量</th><th></th></tr></thead><tbody>${items.map((item) => `<tr><td><code>#${escapeHTML(item.id)}</code></td><td>${escapeHTML(item.user?.username || item.user?.email || item.user_id || '-')}</td><td>${escapeHTML(item.plan_name || item.plan_id || '-')}</td><td>${statusLabel(item.status)}</td><td>${formatDate(item.starts_at)}<br><span class="muted">至 ${formatDate(item.expires_at)}</span></td><td>${formatNumber(item.cycle_usage_usd)} / ${formatNumber(item.cycle_quota_usd)}</td><td><button class="link-button detail-button" data-id="${escapeHTML(item.id)}" type="button">详情</button></td></tr>`).join('')}</tbody></table>`
container.querySelectorAll('.detail-button').forEach((button) => button.addEventListener('click', () => showDetail(button.dataset.id)))
}
async function showDetail(id) { try { const payload = unwrap(await request(`/api/subscriptions/${encodeURIComponent(id)}`)); $('#detail-content').textContent = JSON.stringify(payload, null, 2); $('#detail-dialog').showModal() } catch (error) { showError(error.message) } }
function renderAudit(items) { const container = $('#audit-list'); if (!items.length) { container.innerHTML = '<p class="empty">暂无操作记录</p>'; return }; container.innerHTML = `<table><thead><tr><th>时间</th><th>动作</th><th>结果</th><th>用户 ID</th><th>请求 ID</th></tr></thead><tbody>${items.slice().reverse().map((item) => `<tr><td>${formatDate(item.time)}</td><td><code>${escapeHTML(item.action)}</code></td><td>${statusLabel(item.result)}</td><td>${escapeHTML(item.user_id ?? '-')}</td><td><code>${escapeHTML(item.request_id || '-')}</code></td></tr>`).join('')}</tbody></table>` }
loginForm.addEventListener('submit', login)
$('#logout').addEventListener('click', async () => { try { await request('/logout', { method: 'POST' }) } catch { /* session is cleared locally */ } state.csrf = ''; loginView.hidden = false; appView.hidden = true; loginForm.reset() })
$('#refresh-all').addEventListener('click', loadOverview)
$('#subscription-filter').addEventListener('submit', (event) => { event.preventDefault(); state.subscriptionPage = 1; state.subscriptions = null; loadSubscriptions() })
$('#subscription-prev').addEventListener('click', () => { if (state.subscriptionPage > 1) { state.subscriptionPage -= 1; loadSubscriptions() } })
$('#subscription-next').addEventListener('click', () => { const pages = Number(state.subscriptions?.pages || 0); if (state.subscriptionPage < pages) { state.subscriptionPage += 1; loadSubscriptions() } })
$('#balance-form').addEventListener('submit', async (event) => { event.preventDefault(); clearError(); const userID = String(new FormData(event.currentTarget).get('user_id') || '').trim(); try { await loadBalance(userID) } catch (error) { showError(error.message) } })
$('#close-detail').addEventListener('click', () => $('#detail-dialog').close())
document.querySelectorAll('.tab').forEach((button) => button.addEventListener('click', () => setView(button.dataset.view)))
document.querySelectorAll('.reload').forEach((button) => button.addEventListener('click', () => { if (button.dataset.target === 'plans') loadPlans(); if (button.dataset.target === 'subscriptions') { state.subscriptions = null; loadSubscriptions() }; if (button.dataset.target === 'audit') loadAudit() }))
void bootstrap()
})()
+9
View File
@@ -0,0 +1,9 @@
package ui
import "embed"
// FS contains the static administrator UI shipped with the business plugin.
// It is embedded so the service does not depend on a writable host directory.
//
//go:embed index.html app.js styles.css
var FS embed.FS
+122
View File
@@ -0,0 +1,122 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>订阅管理插件</title>
<link rel="stylesheet" href="__PLUGIN_BASE_PATH__/styles.css">
</head>
<body data-base-path="__PLUGIN_BASE_PATH__">
<main class="shell">
<section id="login-view" class="auth-panel" aria-labelledby="login-title">
<div class="eyebrow">SUB2API EXTENSION</div>
<h1 id="login-title">订阅管理</h1>
<p class="muted">使用 Sub2API 管理员账号登录。普通用户没有访问权限。</p>
<form id="login-form" class="stack" autocomplete="on">
<label>
<span>管理员邮箱</span>
<input name="email" type="email" autocomplete="username" required maxlength="254">
</label>
<label>
<span>密码</span>
<input name="password" type="password" autocomplete="current-password" required maxlength="200">
</label>
<fieldset id="captcha-panel" class="captcha-panel" hidden>
<legend id="captcha-label">安全验证</legend>
<p id="captcha-help" class="muted"></p>
<label>
<span>验证结果</span>
<textarea name="captcha_token" rows="3" maxlength="8192" spellcheck="false" autocomplete="off"></textarea>
</label>
<label id="captcha-randstr-row" hidden>
<span>验证随机串</span>
<input name="captcha_randstr" maxlength="512" autocomplete="off">
</label>
</fieldset>
<button class="primary" type="submit">登录</button>
<p id="login-error" class="error" role="alert" hidden></p>
</form>
</section>
<section id="app-view" hidden>
<header class="topbar">
<div>
<div class="eyebrow">SUB2API EXTENSION</div>
<h1>订阅管理</h1>
</div>
<div class="top-actions">
<span id="operator" class="operator"></span>
<button id="logout" class="secondary" type="button">退出</button>
</div>
</header>
<nav class="tabs" aria-label="插件页面">
<button class="tab active" data-view="overview" type="button">概览</button>
<button class="tab" data-view="plans" type="button">套餐</button>
<button class="tab" data-view="subscriptions" type="button">用户订阅</button>
<button class="tab" data-view="audit" type="button">操作记录</button>
<button class="tab" data-view="settings" type="button">设置</button>
</nav>
<p id="app-error" class="error" role="alert" hidden></p>
<section id="view-overview" class="view stack">
<div class="section-heading"><div><h2>连接概览</h2><p class="muted">Core 是套餐、余额和订阅账本的唯一来源。</p></div><button id="refresh-all" class="secondary" type="button">刷新数据</button></div>
<div class="metric-grid">
<article class="metric"><span>插件状态</span><strong class="status ok">运行中</strong><small>独立服务 · 只读模式</small></article>
<article class="metric"><span>Core 连接</span><strong id="core-status" class="status">检查中</strong><small id="sync-time">尚未同步</small></article>
<article class="metric"><span>可售套餐</span><strong id="plan-count">-</strong><small>来自 Core 套餐目录</small></article>
<article class="metric"><span>订阅实例</span><strong id="subscription-count">-</strong><small>当前分页结果</small></article>
<article class="metric"><span>用户余额</span><strong id="user-balance">-</strong><small>选择用户后显示 Core 余额</small></article>
</div>
<form id="balance-form" class="filter-row balance-form">
<label><span>查询用户余额</span><input name="user_id" inputmode="numeric" pattern="[1-9][0-9]*" placeholder="用户 ID" required></label>
<button class="secondary" type="submit">查询余额</button>
</form>
<div class="notice"><strong>只读试验版</strong><span>余额购买、续费、撤销和外部支付尚未启用。页面不会提交任何写操作。</span></div>
</section>
<section id="view-plans" class="view" hidden>
<div class="section-heading"><div><h2>套餐目录</h2><p class="muted">展示 Core 当前返回的价格、额度和覆盖分组。</p></div><button class="secondary reload" data-target="plans" type="button">刷新</button></div>
<div id="plans-list" class="card-list"></div>
</section>
<section id="view-subscriptions" class="view" hidden>
<div class="section-heading"><div><h2>用户订阅</h2><p class="muted">每个订阅实例独立展示,支持同档位多实例。</p></div><button class="secondary reload" data-target="subscriptions" type="button">刷新</button></div>
<form id="subscription-filter" class="filter-row">
<label><span>用户 ID</span><input name="user_id" inputmode="numeric" pattern="[0-9]*"></label>
<label><span>状态</span><select name="status"><option value="">全部</option><option value="active">active</option><option value="expired">expired</option><option value="revoked">revoked</option></select></label>
<button class="secondary" type="submit">筛选</button>
</form>
<div id="subscriptions-list" class="table-wrap"></div>
<div id="subscription-pagination" class="pagination" hidden>
<span id="subscription-page-info" class="muted"></span>
<div><button id="subscription-prev" class="secondary" type="button">上一页</button><button id="subscription-next" class="secondary" type="button">下一页</button></div>
</div>
</section>
<section id="view-audit" class="view" hidden>
<div class="section-heading"><div><h2>操作记录</h2><p class="muted">仅记录插件会话和只读查询结果,不记录 token 或密码。</p></div><button class="secondary reload" data-target="audit" type="button">刷新</button></div>
<div id="audit-list" class="table-wrap"></div>
</section>
<section id="view-settings" class="view stack" hidden>
<div class="section-heading"><div><h2>插件设置</h2><p class="muted">部署参数由服务端环境变量管理,页面不提供 secret 编辑入口。</p></div></div>
<div class="settings-list">
<div><span>运行模式</span><strong>只读 V1</strong></div>
<div><span>Core API allowlist</span><strong>10 个固定端点</strong></div>
<div><span>会话存储</span><strong>进程内存(重启后需重新登录)</strong></div>
<div><span>Core 凭据</span><strong id="credential-status">检查中</strong></div>
<div><span>余额购买 / 续费 / 撤销</span><strong class="status">未启用</strong></div>
</div>
</section>
</section>
</main>
<dialog id="detail-dialog" class="detail-dialog">
<div class="dialog-heading"><h2>订阅详情</h2><button id="close-detail" class="icon-button" type="button" aria-label="关闭">×</button></div>
<pre id="detail-content"></pre>
</dialog>
<script src="__PLUGIN_BASE_PATH__/app.js" defer></script>
</body>
</html>
+103
View File
@@ -0,0 +1,103 @@
:root { color-scheme: light dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #17202a; background: #f4f7fa; font-synthesis: none; }
@media (prefers-color-scheme: dark) { :root { color: #edf2f7; background: #11161c; } }
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body { margin: 0; min-width: 320px; }
button, input, select { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: wait; opacity: .6; }
.shell { width: min(1160px, calc(100% - 32px)); margin: 0 auto; padding: 32px 0 64px; }
.auth-panel { width: min(440px, 100%); margin: 10vh auto 0; padding: 32px; border: 1px solid #dce4eb; border-radius: 10px; background: #fff; box-shadow: 0 12px 36px rgb(20 38 56 / 8%); }
@media (prefers-color-scheme: dark) { .auth-panel, .metric, .plan-card, .notice, table, .detail-dialog { background: #18212b; border-color: #2b3947; } }
h1, h2, h3, p { margin: 0; }
h1 { margin-top: 6px; font-size: clamp(1.5rem, 3vw, 2.1rem); letter-spacing: 0; }
h2 { font-size: 1.2rem; }
h3 { font-size: 1rem; }
.eyebrow { color: #3977a9; font-size: .7rem; font-weight: 700; letter-spacing: .08em; }
.muted { color: #647384; font-size: .86rem; line-height: 1.5; }
@media (prefers-color-scheme: dark) { .muted { color: #a8b5c2; } }
.stack { display: grid; gap: 18px; }
form.stack { margin-top: 26px; }
label { display: grid; gap: 7px; color: #4f6070; font-size: .82rem; font-weight: 600; }
input, select, textarea { width: 100%; height: 36px; padding: 0 10px; border: 1px solid #cbd7e1; border-radius: 5px; color: inherit; background: transparent; outline: none; }
textarea { height: auto; min-height: 72px; padding: 8px 10px; resize: vertical; font: .78rem/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; }
input:focus, select:focus { border-color: #3977a9; box-shadow: 0 0 0 3px rgb(57 119 169 / 17%); }
button { min-height: 36px; border-radius: 5px; border: 1px solid transparent; padding: 0 14px; }
.primary { color: white; background: #3977a9; }
.primary:hover { background: #2e628e; }
.secondary { color: #2d536f; background: transparent; border-color: #b9c9d7; }
.secondary:hover { background: rgb(57 119 169 / 8%); }
.topbar, .section-heading, .card-heading, .top-actions, .filter-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.topbar { padding-bottom: 24px; border-bottom: 1px solid #d8e1e9; }
.top-actions { flex-wrap: wrap; justify-content: flex-end; }
.operator { max-width: 260px; overflow: hidden; color: #647384; font-size: .82rem; text-overflow: ellipsis; white-space: nowrap; }
.tabs { display: flex; gap: 6px; overflow-x: auto; padding: 18px 0; border-bottom: 1px solid #d8e1e9; }
.tab { color: #647384; background: transparent; border: 0; white-space: nowrap; }
.tab.active { color: #3977a9; box-shadow: inset 0 -2px #3977a9; }
.view { padding-top: 28px; }
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }
.metric { min-height: 122px; display: grid; align-content: space-between; gap: 8px; padding: 18px; border: 1px solid #dce4eb; border-radius: 8px; background: #fff; }
.metric > span { color: #647384; font-size: .82rem; }
.metric strong { font-size: 1.55rem; font-variant-numeric: tabular-nums; }
.metric small { color: #7b8996; font-size: .75rem; }
.status { color: #647384; }
.status.ok { color: #2f8b5c; }
.status.bad { color: #c14f4f; }
.notice { display: flex; gap: 12px; align-items: baseline; padding: 14px 16px; border: 1px solid #c6dce9; border-left: 3px solid #3977a9; border-radius: 6px; background: #fff; font-size: .86rem; }
.card-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-top: 18px; }
.plan-card { display: grid; gap: 15px; padding: 20px; border: 1px solid #dce4eb; border-radius: 8px; background: #fff; }
.plan-price { color: #3977a9; font-size: 1.7rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.plan-price small { color: #647384; font-size: .8rem; font-weight: 500; }
.facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 0; }
.facts div { padding: 10px; border-radius: 5px; background: rgb(100 115 132 / 8%); }
.facts dt { color: #647384; font-size: .72rem; }
.facts dd { margin: 4px 0 0; font-size: .86rem; font-variant-numeric: tabular-nums; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; }
.tag, .pill { display: inline-flex; align-items: center; min-height: 24px; padding: 0 8px; border-radius: 99px; font-size: .72rem; white-space: nowrap; }
.tag { color: #3977a9; background: rgb(57 119 169 / 11%); }
.pill { color: #647384; background: rgb(100 115 132 / 12%); }
.pill-active, .pill-success { color: #2f8b5c; background: rgb(47 139 92 / 13%); }
.pill-expired, .pill-revoked, .pill-failed, .pill-bad { color: #bd5050; background: rgb(189 80 80 / 13%); }
.feature-list { display: grid; gap: 5px; margin: 0; padding-left: 18px; color: #647384; font-size: .82rem; }
.filter-row { justify-content: flex-start; flex-wrap: wrap; margin-top: 18px; }
.filter-row label { width: min(220px, 100%); }
.balance-form { margin-top: 16px; }
.captcha-panel { display: grid; gap: 10px; margin: 2px 0 0; padding: 12px; border: 1px solid #dce4eb; border-radius: 6px; }
.captcha-panel legend { padding: 0 4px; color: #4f6070; font-size: .82rem; font-weight: 600; }
.pagination { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 12px; }
.pagination > div { display: flex; gap: 8px; }
.settings-list { display: grid; gap: 1px; overflow: hidden; border: 1px solid #dce4eb; border-radius: 8px; background: #dce4eb; }
.settings-list > div { display: flex; justify-content: space-between; gap: 18px; padding: 15px 16px; background: #fff; font-size: .84rem; }
.settings-list strong { font-weight: 600; text-align: right; }
@media (prefers-color-scheme: dark) { .settings-list { border-color: #2b3947; background: #2b3947; } .settings-list > div { background: #18212b; } }
.table-wrap { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; margin-top: 18px; border: 1px solid #dce4eb; border-radius: 8px; }
table { width: 100%; min-width: 760px; border-collapse: collapse; background: #fff; }
th, td { padding: 13px 14px; border-bottom: 1px solid #e2e8ee; text-align: left; vertical-align: top; font-size: .82rem; white-space: nowrap; }
th { color: #647384; font-size: .74rem; font-weight: 600; background: rgb(100 115 132 / 6%); }
tr:last-child td { border-bottom: 0; }
code { color: #3977a9; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .78rem; }
.link-button { min-height: 28px; padding: 0; color: #3977a9; background: transparent; border: 0; }
.empty { padding: 32px; color: #647384; text-align: center; }
.error { color: #b84d4d; font-size: .84rem; }
.detail-dialog { width: min(720px, calc(100% - 28px)); max-height: min(700px, calc(100dvh - 28px)); padding: 0; border: 1px solid #dce4eb; border-radius: 8px; color: inherit; background: #fff; }
.detail-dialog::backdrop { background: rgb(15 27 39 / 42%); }
.dialog-heading { display: flex; justify-content: space-between; align-items: center; padding: 16px 18px; border-bottom: 1px solid #dce4eb; }
.icon-button { width: 32px; min-height: 32px; padding: 0; color: #647384; background: transparent; border: 0; font-size: 1.3rem; }
pre { max-height: 580px; overflow: auto; margin: 0; padding: 18px; font: .76rem/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
@media (max-width: 760px) {
.shell { width: min(100% - 20px, 600px); padding-top: 18px; }
.auth-panel { margin-top: 4vh; padding: 22px; }
.topbar, .section-heading { align-items: flex-start; flex-direction: column; }
.top-actions { width: 100%; justify-content: space-between; }
.metric-grid, .card-list { grid-template-columns: 1fr; }
.metric { min-height: 104px; }
.notice { align-items: flex-start; flex-direction: column; gap: 5px; }
.filter-row { align-items: stretch; flex-direction: column; }
.filter-row label { width: 100%; }
.filter-row button { width: 100%; }
.pagination { align-items: stretch; flex-direction: column; }
.pagination > div { width: 100%; }
.pagination button { flex: 1; }
.settings-list > div { align-items: flex-start; flex-direction: column; gap: 5px; }
.settings-list strong { text-align: left; }
}