Compare commits
7 Commits
v2.0.0
...
91eee02243
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91eee02243 | ||
|
|
629d6d5dbc | ||
|
|
b822993489 | ||
|
|
eae2319328 | ||
|
|
ae8706f8c8 | ||
|
|
f4a11fb770 | ||
|
|
c21edfdf1a |
@@ -28,5 +28,25 @@ func newAdminAPIHandler() http.HandlerFunc {
|
||||
UserItem: handleAdminUserItem,
|
||||
|
||||
AuditLogs: handleAdminAuditLogs,
|
||||
|
||||
PluginArtifacts: handleAdminPluginArtifacts,
|
||||
PluginArtifact: handleAdminPluginArtifact,
|
||||
PluginSources: handleAdminPluginSources,
|
||||
PluginBuilds: handleAdminPluginBuilds,
|
||||
PluginBuild: handleAdminPluginBuild,
|
||||
PluginGC: handleAdminPluginGC,
|
||||
PluginOperationsGC: handleAdminPluginOperationsGC,
|
||||
PluginsList: handleAdminPluginsList,
|
||||
PluginItem: handleAdminPluginItem,
|
||||
PluginAction: handleAdminPluginAction,
|
||||
PluginConfig: handleAdminPluginConfig,
|
||||
PluginSecrets: handleAdminPluginSecrets,
|
||||
PluginRollback: handleAdminPluginRollback,
|
||||
PluginOperations: handleAdminPluginOperations,
|
||||
PluginDraining: handleAdminPluginDraining,
|
||||
PluginDispatch: handleAdminPluginDispatchPlan,
|
||||
PluginGovernance: handleAdminPluginGovernance,
|
||||
PluginAdvisories: handleAdminPluginAdvisories,
|
||||
PluginDiagnostics: handleAdminPluginDiagnostics,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,12 +2,16 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tursom/mc-gateway/internal/pluginmanager"
|
||||
)
|
||||
|
||||
func TestAdminSetupLoginAndPermissions(t *testing.T) {
|
||||
@@ -215,6 +219,258 @@ func TestAdminUserPatchInvalidatesExistingSession(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPluginPhase4API(t *testing.T) {
|
||||
handler := newAdminTestHandlerWithAdmin(t)
|
||||
pluginsManager = pluginmanager.New(pluginmanager.Options{
|
||||
DB: adminDB,
|
||||
ArtifactRoot: filepath.Join(filepath.Dir(adminDBPath), "plugins", "artifacts"),
|
||||
Adapter: gatewayTestPluginAdapter{},
|
||||
})
|
||||
adminToken := adminTestLogin(t, handler, "admin", "secret")
|
||||
resp := adminTestRequest(t, handler, http.MethodPost, "/admin/api/users", adminToken, map[string]any{
|
||||
"username": "member",
|
||||
"role": "member",
|
||||
"password": "member-secret",
|
||||
})
|
||||
if resp.Code != http.StatusCreated {
|
||||
t.Fatalf("create member status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
memberToken := adminTestLogin(t, handler, "member", "member-secret")
|
||||
|
||||
artifact := uploadGatewayPhase4Artifact(t, "phase4-plugin")
|
||||
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "phase4-plugin", artifact.ID, pluginmanager.DesiredEnabled, `{"token":"old","host":"a"}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
|
||||
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/plugins", memberToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("member plugins list status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase4-plugin/secrets", memberToken, map[string]any{
|
||||
"name": "api_token",
|
||||
"value": "member-secret-value",
|
||||
})
|
||||
if resp.Code != http.StatusForbidden {
|
||||
t.Fatalf("member secret write status = %d, want forbidden; body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase4-plugin/secrets", adminToken, map[string]any{
|
||||
"artifact_id": artifact.ID,
|
||||
"name": "api_token",
|
||||
"value": "super-secret-value",
|
||||
"reload_required": true,
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("secret write status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if strings.Contains(resp.Body.String(), "super-secret-value") {
|
||||
t.Fatalf("secret response leaked value: %s", resp.Body.String())
|
||||
}
|
||||
|
||||
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase4-plugin/config/dry-run", adminToken, map[string]any{
|
||||
"artifact_id": artifact.ID,
|
||||
"config_json": `{"token":"new","host":"b"}`,
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("config dry-run status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if strings.Contains(resp.Body.String(), "new") || strings.Contains(resp.Body.String(), "old") {
|
||||
t.Fatalf("dry-run leaked sensitive value: %s", resp.Body.String())
|
||||
}
|
||||
|
||||
resp = adminTestRequest(t, handler, http.MethodPut, "/admin/api/plugins/phase4-plugin/config", adminToken, map[string]any{
|
||||
"artifact_id": artifact.ID,
|
||||
"config_json": `{"token":"new","host":"b"}`,
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("config update status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/plugins/phase4-plugin/config/snapshots", adminToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("snapshots status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
snapshotID := firstSnapshotID(t, resp)
|
||||
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/plugins/phase4-plugin/config/snapshots/"+strconv.FormatInt(snapshotID, 10)+"/diff", adminToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("snapshot diff status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if strings.Contains(resp.Body.String(), "new") || strings.Contains(resp.Body.String(), "old") {
|
||||
t.Fatalf("snapshot diff leaked sensitive value: %s", resp.Body.String())
|
||||
}
|
||||
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase4-plugin/rollback/config", adminToken, map[string]any{
|
||||
"snapshot_id": snapshotID,
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("config rollback status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/audit-logs", adminToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("audit status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if strings.Contains(resp.Body.String(), "super-secret-value") {
|
||||
t.Fatalf("audit leaked secret value: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPluginPhase5GovernanceAPI(t *testing.T) {
|
||||
handler := newAdminTestHandlerWithAdmin(t)
|
||||
pluginsManager = pluginmanager.New(pluginmanager.Options{
|
||||
DB: adminDB,
|
||||
ArtifactRoot: filepath.Join(filepath.Dir(adminDBPath), "plugins", "artifacts"),
|
||||
Adapter: gatewayTestPluginAdapter{},
|
||||
})
|
||||
adminToken := adminTestLogin(t, handler, "admin", "secret")
|
||||
resp := adminTestRequest(t, handler, http.MethodPost, "/admin/api/users", adminToken, map[string]any{
|
||||
"username": "member",
|
||||
"role": "member",
|
||||
"password": "member-secret",
|
||||
})
|
||||
if resp.Code != http.StatusCreated {
|
||||
t.Fatalf("create member status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
memberToken := adminTestLogin(t, handler, "member", "member-secret")
|
||||
|
||||
artifact := uploadGatewayPhase5ProtocolProxyArtifact(t, "phase5-proxy")
|
||||
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "phase5-proxy", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/plugins/phase5-proxy/governance?profile=prod", memberToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("member governance status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "review_required") {
|
||||
t.Fatalf("governance status body = %s, want review_required", resp.Body.String())
|
||||
}
|
||||
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase5-proxy/governance/review", memberToken, map[string]any{
|
||||
"artifact_id": artifact.ID,
|
||||
"profile": pluginmanager.PolicyProfileProd,
|
||||
})
|
||||
if resp.Code != http.StatusForbidden {
|
||||
t.Fatalf("member review write status = %d, want forbidden; body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase5-proxy/governance/review", adminToken, map[string]any{
|
||||
"artifact_id": artifact.ID,
|
||||
"profile": pluginmanager.PolicyProfileProd,
|
||||
"decision": pluginmanager.ReviewDecisionApproved,
|
||||
"notes": "phase 5 approval",
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("admin review write status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase5-proxy/governance/preflight", adminToken, map[string]any{
|
||||
"artifact_id": artifact.ID,
|
||||
"profile": pluginmanager.PolicyProfileProd,
|
||||
"action": pluginmanager.GovernanceActionEnable,
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("preflight status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase5-proxy/governance/benchmark", adminToken, map[string]any{
|
||||
"artifact_id": artifact.ID,
|
||||
"profile": pluginmanager.PolicyProfileProd,
|
||||
"benchmark_profile": "release",
|
||||
"p95_ms": 10,
|
||||
"p99_ms": 20,
|
||||
"baseline_diff": 0.25,
|
||||
"active_proxy_capacity": 100,
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("benchmark status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase5-proxy/governance/override", adminToken, map[string]any{
|
||||
"artifact_id": artifact.ID,
|
||||
"profile": pluginmanager.PolicyProfileProd,
|
||||
"action": pluginmanager.GovernanceActionEnable,
|
||||
"reason": "accepted warning for rollout",
|
||||
"ttl_seconds": 3600,
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("override status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugin-advisories", adminToken, map[string]any{
|
||||
"advisory_id": "MCG-2026-ADMIN",
|
||||
"status": pluginmanager.AdvisoryStatusRevoked,
|
||||
"action": pluginmanager.AdvisoryActionRevoke,
|
||||
"artifact_sha256": artifact.SHA256,
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("advisory status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/plugin-advisories?plugin_id=phase5-proxy", memberToken, nil)
|
||||
if resp.Code != http.StatusOK || !strings.Contains(resp.Body.String(), "MCG-2026-ADMIN") {
|
||||
t.Fatalf("member advisory read status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/audit-logs", adminToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("audit status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
for _, action := range []string{"plugin_governance_review", "plugin_governance_override", "plugin_governance_advisory"} {
|
||||
if !strings.Contains(resp.Body.String(), action) {
|
||||
t.Fatalf("audit body missing %s: %s", action, resp.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func uploadGatewayPhase4Artifact(t *testing.T, pluginID string) pluginmanager.ArtifactRecord {
|
||||
t.Helper()
|
||||
var manifest pluginmanager.Manifest
|
||||
if err := json.Unmarshal(gatewayTestManifest(t, pluginID), &manifest); err != nil {
|
||||
t.Fatalf("Unmarshal manifest error = %v", err)
|
||||
}
|
||||
manifest.ConfigSchema = json.RawMessage(`{"type":"object","properties":{"token":{"type":"string","sensitive":true},"host":{"type":"string"}}}`)
|
||||
manifest.Secrets = []pluginmanager.SecretSpec{{
|
||||
Name: "api_token",
|
||||
Required: false,
|
||||
Type: "api_token",
|
||||
Rotation: pluginmanager.SecretRotation{
|
||||
Reload: "reload_required",
|
||||
},
|
||||
}}
|
||||
manifestBytes, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal manifest error = %v", err)
|
||||
}
|
||||
artifact, err := pluginsManager.UploadArtifact(context.Background(), pluginmanager.ArtifactUpload{
|
||||
SourcePath: writeGatewayTestMCGPEntries(t, map[string][]byte{
|
||||
"manifest.json": manifestBytes,
|
||||
"plugin.so": []byte("fake plugin bytes " + pluginID),
|
||||
}),
|
||||
FileName: pluginID + ".mcgp",
|
||||
Actor: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UploadArtifact() error = %v", err)
|
||||
}
|
||||
return artifact
|
||||
}
|
||||
|
||||
func uploadGatewayPhase5ProtocolProxyArtifact(t *testing.T, pluginID string) pluginmanager.ArtifactRecord {
|
||||
t.Helper()
|
||||
var manifest pluginmanager.Manifest
|
||||
if err := json.Unmarshal(gatewayTestManifestWithCapabilities(t, pluginID, gatewayProtocolProxyCapabilities()), &manifest); err != nil {
|
||||
t.Fatalf("Unmarshal manifest error = %v", err)
|
||||
}
|
||||
manifest.RuntimeLimits = pluginmanager.RuntimeLimits{HandlerTimeoutMS: 3000}
|
||||
manifestBytes, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal manifest error = %v", err)
|
||||
}
|
||||
artifact, err := pluginsManager.UploadArtifact(context.Background(), pluginmanager.ArtifactUpload{
|
||||
SourcePath: writeGatewayTestMCGPEntries(t, map[string][]byte{
|
||||
"manifest.json": manifestBytes,
|
||||
"plugin.so": []byte("fake plugin bytes " + pluginID),
|
||||
}),
|
||||
FileName: pluginID + ".mcgp",
|
||||
Actor: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UploadArtifact() error = %v", err)
|
||||
}
|
||||
return artifact
|
||||
}
|
||||
|
||||
func newAdminTestHandler(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
t.Cleanup(saveGatewayState(t))
|
||||
@@ -287,3 +543,21 @@ func adminTestJSON(t *testing.T, resp *httptest.ResponseRecorder) map[string]any
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func firstSnapshotID(t *testing.T, resp *httptest.ResponseRecorder) int64 {
|
||||
t.Helper()
|
||||
body := adminTestJSON(t, resp)
|
||||
snapshots, ok := body["snapshots"].([]any)
|
||||
if !ok || len(snapshots) == 0 {
|
||||
t.Fatalf("snapshots = %#v, want at least one", body["snapshots"])
|
||||
}
|
||||
first, ok := snapshots[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("first snapshot = %#v", snapshots[0])
|
||||
}
|
||||
id, ok := first["id"].(float64)
|
||||
if !ok || id <= 0 {
|
||||
t.Fatalf("snapshot id = %#v", first["id"])
|
||||
}
|
||||
return int64(id)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ func recordAudit(ctx context.Context, actor, sourceIP, action, targetType, targe
|
||||
_ = adminaudit.NewRepository(adminDB).Record(ctx, actor, sourceIP, action, targetType, targetID, success, message)
|
||||
}
|
||||
|
||||
func recordAuditMetadata(ctx context.Context, actor, sourceIP, action, targetType, targetID string, success bool, message string, metadata any) {
|
||||
_ = adminaudit.NewRepository(adminDB).RecordWithMetadata(ctx, actor, sourceIP, action, targetType, targetID, success, message, metadata)
|
||||
}
|
||||
|
||||
func listAuditLogs(ctx context.Context) ([]adminaudit.Record, error) {
|
||||
return adminaudit.NewRepository(adminDB).List(ctx, adminaudit.DefaultListLimit)
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ import { state } from "./state.js";
|
||||
interface APIOptions {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
formData?: FormData;
|
||||
}
|
||||
|
||||
export async function api<T>(path: string, options: APIOptions = {}): Promise<T> {
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
if (options.body !== undefined) {
|
||||
if (options.body !== undefined && !options.formData) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
if (state.token) {
|
||||
@@ -17,7 +18,7 @@ export async function api<T>(path: string, options: APIOptions = {}): Promise<T>
|
||||
const res = await fetch(state.apiBase + path, {
|
||||
method: options.method || "GET",
|
||||
headers,
|
||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
body: options.formData || (options.body === undefined ? undefined : JSON.stringify(options.body)),
|
||||
});
|
||||
|
||||
let data: unknown = {};
|
||||
|
||||
@@ -9,7 +9,9 @@ const translations = {
|
||||
activeUser: "Active",
|
||||
actions: "Actions",
|
||||
actor: "Actor",
|
||||
active: "Active",
|
||||
adminSubtitle: "Admin",
|
||||
artifact: "Artifact",
|
||||
audit: "Audit",
|
||||
cancel: "Cancel",
|
||||
createAdmin: "Create admin",
|
||||
@@ -17,11 +19,15 @@ const translations = {
|
||||
delete: "Delete",
|
||||
deleteDefaultRouteConfirm: "Delete default route?",
|
||||
deleteUserConfirm: "Delete user {username}?",
|
||||
desired: "Desired",
|
||||
desiredArtifact: "Desired artifact",
|
||||
dialErrors: "Dial errors",
|
||||
disabled: "Disabled",
|
||||
edit: "Edit",
|
||||
enabled: "Enabled",
|
||||
extensions: "Extensions",
|
||||
failed: "Failed",
|
||||
health: "Health",
|
||||
host: "Host",
|
||||
initialAdmin: "Initial admin",
|
||||
language: "Language",
|
||||
@@ -32,6 +38,7 @@ const translations = {
|
||||
message: "Message",
|
||||
metrics: "Metrics",
|
||||
misses: "Misses",
|
||||
name: "Name",
|
||||
newRoute: "New route",
|
||||
newUser: "New user",
|
||||
noHits: "No hits",
|
||||
@@ -41,6 +48,10 @@ const translations = {
|
||||
path: "Path",
|
||||
port: "Port",
|
||||
protocols: "Protocols",
|
||||
pluginID: "Plugin ID",
|
||||
plugins: "Plugins",
|
||||
priority: "Priority",
|
||||
refresh: "Refresh",
|
||||
restart: "Restart",
|
||||
restartRequired: "restart required",
|
||||
result: "Result",
|
||||
@@ -52,6 +63,7 @@ const translations = {
|
||||
routeHits: "Route hits",
|
||||
routeSearch: "Search host, upstream, note",
|
||||
routes: "Routes",
|
||||
runtime: "Runtime",
|
||||
save: "Save",
|
||||
services: "Services",
|
||||
setupSubtitle: "Setup",
|
||||
@@ -60,17 +72,21 @@ const translations = {
|
||||
time: "Time",
|
||||
total: "Total",
|
||||
upstream: "Upstream",
|
||||
uploadPlugin: "Upload plugin",
|
||||
uptime: "Uptime",
|
||||
user: "User",
|
||||
username: "Username",
|
||||
users: "Users",
|
||||
version: "Version",
|
||||
},
|
||||
zh: {
|
||||
activeConnections: "活跃连接",
|
||||
activeUser: "启用",
|
||||
actions: "操作",
|
||||
actor: "操作者",
|
||||
active: "当前",
|
||||
adminSubtitle: "管理后台",
|
||||
artifact: "Artifact",
|
||||
audit: "审计",
|
||||
cancel: "取消",
|
||||
createAdmin: "创建管理员",
|
||||
@@ -78,11 +94,15 @@ const translations = {
|
||||
delete: "删除",
|
||||
deleteDefaultRouteConfirm: "确认删除默认路由?",
|
||||
deleteUserConfirm: "确认删除用户 {username}?",
|
||||
desired: "期望",
|
||||
desiredArtifact: "期望 Artifact",
|
||||
dialErrors: "连接上游失败",
|
||||
disabled: "禁用",
|
||||
edit: "编辑",
|
||||
enabled: "启用",
|
||||
extensions: "扩展点",
|
||||
failed: "失败",
|
||||
health: "健康",
|
||||
host: "主机",
|
||||
initialAdmin: "初始化管理员",
|
||||
language: "语言",
|
||||
@@ -93,6 +113,7 @@ const translations = {
|
||||
message: "消息",
|
||||
metrics: "指标",
|
||||
misses: "未命中",
|
||||
name: "名称",
|
||||
newRoute: "新建路由",
|
||||
newUser: "新建用户",
|
||||
noHits: "暂无命中",
|
||||
@@ -102,6 +123,10 @@ const translations = {
|
||||
path: "路径",
|
||||
port: "端口",
|
||||
protocols: "协议",
|
||||
pluginID: "插件 ID",
|
||||
plugins: "插件",
|
||||
priority: "优先级",
|
||||
refresh: "刷新",
|
||||
restart: "重启",
|
||||
restartRequired: "需要重启",
|
||||
result: "结果",
|
||||
@@ -113,6 +138,7 @@ const translations = {
|
||||
routeHits: "路由命中",
|
||||
routeSearch: "搜索主机、上游、备注",
|
||||
routes: "路由",
|
||||
runtime: "运行时",
|
||||
save: "保存",
|
||||
services: "服务",
|
||||
setupSubtitle: "初始化",
|
||||
@@ -121,10 +147,12 @@ const translations = {
|
||||
time: "时间",
|
||||
total: "总数",
|
||||
upstream: "上游",
|
||||
uploadPlugin: "上传插件",
|
||||
uptime: "运行时间",
|
||||
user: "用户",
|
||||
username: "用户名",
|
||||
users: "用户",
|
||||
version: "版本",
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { setToken, state } from "./state.js";
|
||||
import type { LoginResponse, SetupStatus, User } from "./types.js";
|
||||
import { loadAudit } from "./views/audit.js";
|
||||
import { loadMetrics } from "./views/metrics.js";
|
||||
import { bindPluginEvents, loadPlugins, renderPluginDetail, renderPlugins } from "./views/plugins.js";
|
||||
import { loadRoutes, openRouteDialog, renderRoutes, saveRoute } from "./views/routes.js";
|
||||
import { loadServices, renderServices } from "./views/services.js";
|
||||
import { loadStatus } from "./views/status.js";
|
||||
@@ -56,6 +57,7 @@ function bindEvents(): void {
|
||||
el("newUserBtn").addEventListener("click", () => openUserDialog());
|
||||
el<HTMLFormElement>("routeForm").addEventListener("submit", saveRoute);
|
||||
el<HTMLFormElement>("userForm").addEventListener("submit", saveUser);
|
||||
bindPluginEvents();
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-close]").forEach((button) => {
|
||||
button.addEventListener("click", () => button.closest("dialog")?.close());
|
||||
@@ -132,6 +134,7 @@ async function showApp(): Promise<void> {
|
||||
await loadStatus();
|
||||
await loadServices();
|
||||
await loadMetrics();
|
||||
await loadPlugins();
|
||||
}
|
||||
if (isAdmin()) {
|
||||
await loadUsers();
|
||||
@@ -145,6 +148,7 @@ function applyRoleVisibility(): void {
|
||||
el("statusGrid").classList.toggle("hidden", !member);
|
||||
el("newRouteBtn").classList.toggle("hidden", !member);
|
||||
toggleTab("services", member);
|
||||
toggleTab("plugins", member);
|
||||
toggleTab("metrics", member);
|
||||
toggleTab("users", admin);
|
||||
toggleTab("audit", admin);
|
||||
@@ -176,9 +180,12 @@ function rerenderCurrentView(): void {
|
||||
renderRoutes();
|
||||
renderServices();
|
||||
renderUsers();
|
||||
renderPlugins();
|
||||
renderPluginDetail();
|
||||
if (isMember()) {
|
||||
loadStatus();
|
||||
loadMetrics();
|
||||
loadPlugins();
|
||||
}
|
||||
if (isAdmin()) {
|
||||
loadAudit();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RouteRecord, ServiceRecord, User } from "./types.js";
|
||||
import type { PluginArtifact, PluginBuild, PluginView, RouteRecord, ServiceRecord, User } from "./types.js";
|
||||
|
||||
export const tokenStorageKey = "mcGatewayAdminToken";
|
||||
export const languageStorageKey = "mcGatewayAdminLanguage";
|
||||
@@ -11,6 +11,11 @@ export interface AppState {
|
||||
routes: RouteRecord[];
|
||||
services: ServiceRecord[];
|
||||
users: User[];
|
||||
plugins: PluginView[];
|
||||
pluginArtifacts: PluginArtifact[];
|
||||
pluginBuilds: PluginBuild[];
|
||||
selectedPluginID: string;
|
||||
selectedArtifactID: string;
|
||||
}
|
||||
|
||||
export const state: AppState = {
|
||||
@@ -21,6 +26,11 @@ export const state: AppState = {
|
||||
routes: [],
|
||||
services: [],
|
||||
users: [],
|
||||
plugins: [],
|
||||
pluginArtifacts: [],
|
||||
pluginBuilds: [],
|
||||
selectedPluginID: "",
|
||||
selectedArtifactID: "",
|
||||
};
|
||||
|
||||
export function setToken(token: string): void {
|
||||
|
||||
55
cmd/gateway/admin_frontend/src/types.d.ts
vendored
55
cmd/gateway/admin_frontend/src/types.d.ts
vendored
@@ -1,55 +0,0 @@
|
||||
export type Role = "admin" | "member" | "guest";
|
||||
|
||||
export interface User {
|
||||
username: string;
|
||||
role: Role;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export interface RouteRecord {
|
||||
host: string;
|
||||
upstream: string;
|
||||
enabled: boolean;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface ServiceRecord {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
options?: Record<string, unknown>;
|
||||
restart_required?: boolean;
|
||||
}
|
||||
|
||||
export interface Metrics {
|
||||
total_connections?: number;
|
||||
active_connections?: number;
|
||||
tcp_connections?: number;
|
||||
websocket_connections?: number;
|
||||
route_misses?: number;
|
||||
upstream_dial_errors?: number;
|
||||
route_hits?: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface AuditLog {
|
||||
created_at: number;
|
||||
actor: string;
|
||||
action: string;
|
||||
target_type: string;
|
||||
target_id: string;
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface SetupStatus {
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface RuntimeConfig {
|
||||
apiPrefix: string;
|
||||
}
|
||||
231
cmd/gateway/admin_frontend/src/types.ts
Normal file
231
cmd/gateway/admin_frontend/src/types.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
export type Role = "admin" | "member" | "guest";
|
||||
|
||||
export interface User {
|
||||
username: string;
|
||||
role: Role;
|
||||
disabled: boolean;
|
||||
permissions?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface RouteRecord {
|
||||
host: string;
|
||||
upstream: string;
|
||||
enabled: boolean;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface ServiceRecord {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
options?: Record<string, unknown>;
|
||||
restart_required?: boolean;
|
||||
}
|
||||
|
||||
export interface Metrics {
|
||||
total_connections?: number;
|
||||
active_connections?: number;
|
||||
tcp_connections?: number;
|
||||
websocket_connections?: number;
|
||||
route_misses?: number;
|
||||
upstream_dial_errors?: number;
|
||||
route_hits?: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface AuditLog {
|
||||
created_at: number;
|
||||
actor: string;
|
||||
action: string;
|
||||
target_type: string;
|
||||
target_id: string;
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface PluginArtifact {
|
||||
id: string;
|
||||
plugin_id: string;
|
||||
version: string;
|
||||
file_name?: string;
|
||||
sha256: string;
|
||||
package_sha256?: string;
|
||||
artifact_type: string;
|
||||
runtime_type: string;
|
||||
status: string;
|
||||
metadata_json?: string;
|
||||
capabilities_summary_json?: string;
|
||||
extension_points_json?: string;
|
||||
api_version?: string;
|
||||
go_version?: string;
|
||||
go_os?: string;
|
||||
go_arch?: string;
|
||||
uploaded_by?: string;
|
||||
error?: string;
|
||||
created_at?: number;
|
||||
updated_at?: number;
|
||||
}
|
||||
|
||||
export interface PluginBuild {
|
||||
id: number;
|
||||
plugin_id: string;
|
||||
source_id: string;
|
||||
artifact_id: string;
|
||||
status: string;
|
||||
builder_type: string;
|
||||
go_version?: string;
|
||||
go_os?: string;
|
||||
go_arch?: string;
|
||||
source_sha256?: string;
|
||||
artifact_sha256?: string;
|
||||
log_summary?: string;
|
||||
error?: string;
|
||||
duration_ms?: number;
|
||||
created_at?: number;
|
||||
updated_at?: number;
|
||||
}
|
||||
|
||||
export interface PluginSecret {
|
||||
plugin_id: string;
|
||||
name: string;
|
||||
current_version: number;
|
||||
previous_version: number;
|
||||
reload_required: boolean;
|
||||
hot_reload: boolean;
|
||||
updated_by?: string;
|
||||
updated_at?: number;
|
||||
}
|
||||
|
||||
export interface PluginSnapshot {
|
||||
id: number;
|
||||
plugin_id: string;
|
||||
artifact_id: string;
|
||||
config_json: string;
|
||||
desired_state: string;
|
||||
priority: number;
|
||||
desired_generation: number;
|
||||
created_by?: string;
|
||||
created_at?: number;
|
||||
}
|
||||
|
||||
export interface PluginProxyConnection {
|
||||
id: number;
|
||||
plugin_id: string;
|
||||
artifact_id: string;
|
||||
handler_id: string;
|
||||
started_at: number;
|
||||
duration_ms: number;
|
||||
draining: boolean;
|
||||
}
|
||||
|
||||
export interface GovernanceIssue {
|
||||
code: string;
|
||||
severity: string;
|
||||
message: string;
|
||||
plugin_id?: string;
|
||||
artifact_id?: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GovernanceDecision {
|
||||
ok: boolean;
|
||||
action: string;
|
||||
profile: string;
|
||||
risk_level: string;
|
||||
policy_hash: string;
|
||||
review_required: boolean;
|
||||
warning_override_used: boolean;
|
||||
issues?: GovernanceIssue[];
|
||||
checks?: GovernanceIssue[];
|
||||
}
|
||||
|
||||
export interface GovernanceStatus {
|
||||
decision?: GovernanceDecision;
|
||||
policy?: Record<string, unknown>;
|
||||
reviews?: Record<string, unknown>[];
|
||||
warning_overrides?: Record<string, unknown>[];
|
||||
preflights?: Record<string, unknown>[];
|
||||
benchmarks?: Record<string, unknown>[];
|
||||
advisories?: Record<string, unknown>[];
|
||||
conflicts?: {
|
||||
ok: boolean;
|
||||
issues?: GovernanceIssue[];
|
||||
plan?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PluginView {
|
||||
id: string;
|
||||
name?: string;
|
||||
version?: string;
|
||||
artifact_type?: string;
|
||||
runtime_type?: string;
|
||||
desired_state: string;
|
||||
runtime_state: string;
|
||||
desired_artifact_id: string;
|
||||
active_artifact_id: string;
|
||||
loaded_artifact_id: string;
|
||||
desired_artifact?: PluginArtifact;
|
||||
active_artifact?: PluginArtifact;
|
||||
loaded_artifact?: PluginArtifact;
|
||||
extension_points?: string[];
|
||||
priority: number;
|
||||
scope?: unknown;
|
||||
rollout?: unknown;
|
||||
restart_required?: boolean;
|
||||
health?: string;
|
||||
last_error?: string;
|
||||
runtime_summary?: Record<string, unknown>;
|
||||
dispatch_summary?: unknown[];
|
||||
capabilities_summary?: Record<string, unknown>;
|
||||
minecraft?: unknown;
|
||||
config_json?: string;
|
||||
config_schema?: Record<string, unknown>;
|
||||
secrets?: PluginSecret[];
|
||||
snapshots?: PluginSnapshot[];
|
||||
builds?: PluginBuild[];
|
||||
artifacts?: PluginArtifact[];
|
||||
manifest?: Record<string, unknown>;
|
||||
active_proxy_connections?: number;
|
||||
proxy_connections?: PluginProxyConnection[];
|
||||
governance?: GovernanceStatus;
|
||||
governance_error?: string;
|
||||
updated_at?: number;
|
||||
}
|
||||
|
||||
export interface PluginOperations {
|
||||
handlers?: Record<string, unknown>[];
|
||||
builds?: Record<string, unknown>[];
|
||||
events?: Record<string, unknown>[];
|
||||
custom_metrics?: Record<string, unknown>[];
|
||||
logs?: Record<string, unknown>[];
|
||||
traces?: Record<string, unknown>[];
|
||||
background_tasks?: Record<string, unknown>[];
|
||||
plugin_data?: Record<string, unknown>[];
|
||||
plugin_files?: Record<string, unknown>[];
|
||||
external_dependencies?: Record<string, unknown>[];
|
||||
gc?: Record<string, unknown>[];
|
||||
event_queue?: Record<string, unknown>;
|
||||
diagnostics?: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
export interface PluginDryRunResult {
|
||||
ok: boolean;
|
||||
restart_required: boolean;
|
||||
hot_reload: boolean;
|
||||
sensitive_paths: string[];
|
||||
redacted_config_json: string;
|
||||
redacted_diff_json: string;
|
||||
}
|
||||
|
||||
export interface SetupStatus {
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface RuntimeConfig {
|
||||
apiPrefix: string;
|
||||
}
|
||||
866
cmd/gateway/admin_frontend/src/views/plugins.ts
Normal file
866
cmd/gateway/admin_frontend/src/views/plugins.ts
Normal file
@@ -0,0 +1,866 @@
|
||||
import { api } from "../api.js";
|
||||
import { showAlert } from "../alerts.js";
|
||||
import { badge, el, escapeAttr, escapeHTML, getFormInput } from "../dom.js";
|
||||
import { isAdmin } from "../session.js";
|
||||
import { state } from "../state.js";
|
||||
import type { PluginArtifact, PluginBuild, PluginDryRunResult, PluginOperations, PluginProxyConnection, PluginSecret, PluginSnapshot, PluginView } from "../types.js";
|
||||
|
||||
interface PluginsResponse {
|
||||
plugins?: PluginView[];
|
||||
}
|
||||
|
||||
interface ArtifactsResponse {
|
||||
artifacts?: PluginArtifact[];
|
||||
}
|
||||
|
||||
interface BuildsResponse {
|
||||
builds?: PluginBuild[];
|
||||
}
|
||||
|
||||
interface PluginResponse {
|
||||
plugin?: PluginView;
|
||||
}
|
||||
|
||||
interface DryRunResponse {
|
||||
result?: PluginDryRunResult;
|
||||
}
|
||||
|
||||
interface SnapshotDiffResponse {
|
||||
diff?: {
|
||||
redacted_diff_json?: string;
|
||||
sensitive_paths?: string[];
|
||||
restart_required?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface OperationsResponse {
|
||||
operations?: PluginOperations;
|
||||
candidates?: Record<string, unknown>[];
|
||||
diagnostic?: Record<string, unknown>;
|
||||
summary?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function loadPlugins(): Promise<void> {
|
||||
try {
|
||||
const [data, artifacts, builds] = await Promise.all([
|
||||
api<PluginsResponse>("/plugins"),
|
||||
api<ArtifactsResponse>("/plugin-artifacts"),
|
||||
api<BuildsResponse>("/plugin-builds"),
|
||||
]);
|
||||
state.plugins = data.plugins || [];
|
||||
state.pluginArtifacts = artifacts.artifacts || [];
|
||||
state.pluginBuilds = builds.builds || [];
|
||||
const firstPlugin = state.plugins[0];
|
||||
if (!state.selectedPluginID && firstPlugin) {
|
||||
state.selectedPluginID = firstPlugin.id;
|
||||
}
|
||||
renderPlugins();
|
||||
if (state.selectedPluginID) {
|
||||
await loadPluginDetail(state.selectedPluginID);
|
||||
} else {
|
||||
renderPluginDetail(null);
|
||||
}
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderPlugins(): void {
|
||||
const managed = new Set(state.plugins.map((plugin) => plugin.id));
|
||||
const unmanagedArtifacts = state.pluginArtifacts.filter((artifact) => !managed.has(artifact.plugin_id));
|
||||
el("pluginsBody").innerHTML = state.plugins.map((plugin) => `
|
||||
<tr class="${plugin.id === state.selectedPluginID ? "selected" : ""}">
|
||||
<td><button class="link-button" type="button" data-plugin-detail="${escapeAttr(plugin.id)}">${escapeHTML(plugin.id)}</button></td>
|
||||
<td>${escapeHTML(plugin.name || "")}</td>
|
||||
<td>${escapeHTML(plugin.version || "")}</td>
|
||||
<td>${escapeHTML(plugin.artifact_type || "")}</td>
|
||||
<td>${escapeHTML(plugin.runtime_type || "")}</td>
|
||||
<td>${escapeHTML(plugin.desired_state)}</td>
|
||||
<td>${badge(plugin.runtime_state, plugin.runtime_state !== "enabled")}</td>
|
||||
<td>${escapeHTML(shortID(plugin.desired_artifact_id))}</td>
|
||||
<td>${escapeHTML((plugin.extension_points || []).join(", "))}</td>
|
||||
<td>${escapeHTML(String(plugin.priority))}</td>
|
||||
<td>${badge(plugin.restart_required ? "yes" : "no", !plugin.restart_required)}</td>
|
||||
<td>${badge(plugin.health || "", plugin.health !== "healthy")}</td>
|
||||
</tr>
|
||||
`).concat(unmanagedArtifacts.map((artifact) => `
|
||||
<tr class="${artifact.id === state.selectedArtifactID ? "selected" : ""}">
|
||||
<td><button class="link-button" type="button" data-artifact-detail="${escapeAttr(artifact.id)}">${escapeHTML(artifact.plugin_id)}</button></td>
|
||||
<td>${escapeHTML(artifact.file_name || "")}</td>
|
||||
<td>${escapeHTML(artifact.version || "")}</td>
|
||||
<td>${escapeHTML(artifact.artifact_type)}</td>
|
||||
<td>${escapeHTML(artifact.runtime_type || "")}</td>
|
||||
<td>${escapeHTML(artifact.status)}</td>
|
||||
<td>${badge("not managed", true)}</td>
|
||||
<td>${escapeHTML(shortID(artifact.id))}</td>
|
||||
<td>${escapeHTML(jsonList(artifact.extension_points_json))}</td>
|
||||
<td></td>
|
||||
<td>${badge("no", false)}</td>
|
||||
<td>${badge(artifact.error ? "error" : "pending", Boolean(artifact.error))}</td>
|
||||
</tr>
|
||||
`)).join("");
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-plugin-detail]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const id = button.dataset.pluginDetail;
|
||||
if (id) {
|
||||
state.selectedPluginID = id;
|
||||
state.selectedArtifactID = "";
|
||||
loadPluginDetail(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-artifact-detail]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
state.selectedArtifactID = button.dataset.artifactDetail || "";
|
||||
state.selectedPluginID = "";
|
||||
renderPlugins();
|
||||
renderPluginDetail(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadPluginDetail(pluginID: string): Promise<void> {
|
||||
try {
|
||||
const data = await api<PluginResponse>(`/plugins/${encodeURIComponent(pluginID)}`);
|
||||
if (data.plugin) {
|
||||
state.plugins = state.plugins.map((plugin) => plugin.id === data.plugin?.id ? data.plugin : plugin);
|
||||
if (!state.plugins.some((plugin) => plugin.id === data.plugin?.id)) {
|
||||
state.plugins.push(data.plugin);
|
||||
}
|
||||
state.selectedPluginID = data.plugin.id;
|
||||
renderPlugins();
|
||||
renderPluginDetail(data.plugin);
|
||||
}
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderPluginDetail(plugin: PluginView | null = selectedPlugin()): void {
|
||||
const detail = el("pluginDetail");
|
||||
if (!plugin) {
|
||||
detail.innerHTML = uploadInventoryDetail();
|
||||
bindInventoryEvents();
|
||||
return;
|
||||
}
|
||||
const canWrite = isAdmin();
|
||||
detail.innerHTML = `
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
<h2>${escapeHTML(plugin.name || plugin.id)}</h2>
|
||||
<p>${escapeHTML(plugin.id)} · ${escapeHTML(plugin.version || "")}</p>
|
||||
</div>
|
||||
<div class="row-actions">${canWrite ? pluginActionButtons(plugin) : ""}</div>
|
||||
</div>
|
||||
<div class="status-grid dense">
|
||||
${detailStat("Runtime", plugin.runtime_state)}
|
||||
${detailStat("Desired", plugin.desired_state)}
|
||||
${detailStat("Active", shortID(plugin.active_artifact_id))}
|
||||
${detailStat("Desired artifact", shortID(plugin.desired_artifact_id))}
|
||||
${detailStat("Loaded", shortID(plugin.loaded_artifact_id))}
|
||||
${detailStat("Restart", plugin.restart_required ? "required" : "not required")}
|
||||
${detailStat("Health", plugin.health || "")}
|
||||
${detailStat("Active proxy", plugin.active_proxy_connections || 0)}
|
||||
</div>
|
||||
${plugin.last_error ? `<div class="alert inline-alert">${escapeHTML(plugin.last_error)}</div>` : ""}
|
||||
<div class="plugin-layout">
|
||||
<section class="panel">
|
||||
<h3>Manifest</h3>
|
||||
<dl class="kv">
|
||||
<dt>Runtime</dt><dd>${escapeHTML(plugin.runtime_type || "")}</dd>
|
||||
<dt>Extensions</dt><dd>${escapeHTML((plugin.extension_points || []).join(", "))}</dd>
|
||||
<dt>Scope</dt><dd>${escapeHTML(formatJSON(plugin.scope))}</dd>
|
||||
<dt>Rollout</dt><dd>${escapeHTML(formatJSON(plugin.rollout))}</dd>
|
||||
<dt>Minecraft</dt><dd>${escapeHTML(formatJSON(plugin.minecraft))}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h3>Governance</h3>
|
||||
${governancePanel(plugin, canWrite)}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h3>Config</h3>
|
||||
<textarea id="pluginConfigEditor" ${canWrite ? "" : "readonly"}>${escapeHTML(prettyJSON(plugin.config_json || "{}"))}</textarea>
|
||||
<div class="row-actions">${canWrite ? `
|
||||
<button type="button" id="pluginDryRunBtn">Dry run</button>
|
||||
<button type="button" id="pluginSaveConfigBtn">Save config</button>
|
||||
` : ""}</div>
|
||||
<pre id="pluginDryRunResult" class="log-output"></pre>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h3>Secrets</h3>
|
||||
<div class="chips">${(plugin.secrets || []).map(secretChip).join("") || `<span class="chip">No secrets</span>`}</div>
|
||||
${canWrite ? `
|
||||
<form id="pluginSecretForm" class="inline-form">
|
||||
<input name="name" placeholder="secret name" required>
|
||||
<input name="value" type="password" placeholder="value" required>
|
||||
<label class="inline"><input name="reload_required" type="checkbox"> Reload required</label>
|
||||
<label class="inline"><input name="hot_reload" type="checkbox" checked> Hot reload</label>
|
||||
<button type="submit">Update secret</button>
|
||||
</form>
|
||||
` : ""}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h3>Artifacts</h3>
|
||||
${artifactList(plugin.artifacts || [], plugin)}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h3>Builds</h3>
|
||||
${buildList(plugin.builds || [], plugin, canWrite)}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h3>Snapshots</h3>
|
||||
${snapshotList(plugin.snapshots || [], canWrite)}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h3>Dispatch plan</h3>
|
||||
<pre class="log-output">${escapeHTML(formatJSON(plugin.dispatch_summary || []))}</pre>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h3>Operations</h3>
|
||||
<div class="row-actions">
|
||||
<button class="secondary" type="button" id="pluginOperationsLoadBtn">Refresh</button>
|
||||
<button class="secondary" type="button" id="pluginOperationsGCDryRunBtn">GC dry-run</button>
|
||||
<button class="secondary" type="button" id="pluginDiagnosticBtn">Diagnostic</button>
|
||||
</div>
|
||||
<pre id="pluginOperationsOutput" class="log-output"></pre>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h3>Active proxy connections</h3>
|
||||
${proxyConnectionList(plugin.proxy_connections || [])}
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
bindPluginDetailEvents(plugin);
|
||||
}
|
||||
|
||||
export function bindPluginEvents(): void {
|
||||
el<HTMLInputElement>("pluginUploadInput").addEventListener("change", uploadPluginPackage);
|
||||
el<HTMLButtonElement>("refreshPluginsBtn").addEventListener("click", loadPlugins);
|
||||
}
|
||||
|
||||
async function uploadPluginPackage(event: Event): Promise<void> {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.set("artifact", file);
|
||||
try {
|
||||
await api("/plugin-artifacts", { method: "POST", formData });
|
||||
input.value = "";
|
||||
await loadPlugins();
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
function bindPluginDetailEvents(plugin: PluginView): void {
|
||||
document.getElementById("pluginDryRunBtn")?.addEventListener("click", () => dryRunConfig(plugin));
|
||||
document.getElementById("pluginSaveConfigBtn")?.addEventListener("click", () => saveConfig(plugin));
|
||||
const secretForm = document.getElementById("pluginSecretForm");
|
||||
if (secretForm instanceof HTMLFormElement) {
|
||||
secretForm.addEventListener("submit", (event) => saveSecret(event, plugin));
|
||||
}
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-plugin-action]").forEach((button) => {
|
||||
button.addEventListener("click", () => runPluginAction(plugin.id, button.dataset.pluginAction || ""));
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-artifact-rollback]").forEach((button) => {
|
||||
button.addEventListener("click", () => rollbackArtifact(plugin.id, button.dataset.artifactRollback || ""));
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-build-action]").forEach((button) => {
|
||||
button.addEventListener("click", () => runBuildAction(plugin.id, Number(button.dataset.buildId || "0"), button.dataset.buildAction || ""));
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-snapshot-rollback]").forEach((button) => {
|
||||
button.addEventListener("click", () => rollbackSnapshot(plugin.id, Number(button.dataset.snapshotRollback || "0"), false));
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-snapshot-full-rollback]").forEach((button) => {
|
||||
button.addEventListener("click", () => rollbackSnapshot(plugin.id, Number(button.dataset.snapshotFullRollback || "0"), true));
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-snapshot-diff]").forEach((button) => {
|
||||
button.addEventListener("click", () => showSnapshotDiff(plugin.id, Number(button.dataset.snapshotDiff || "0")));
|
||||
});
|
||||
document.getElementById("pluginGovernanceReviewBtn")?.addEventListener("click", () => createGovernanceReview(plugin));
|
||||
document.getElementById("pluginGovernanceOverrideBtn")?.addEventListener("click", () => createGovernanceOverride(plugin));
|
||||
document.getElementById("pluginGovernancePreflightBtn")?.addEventListener("click", () => runGovernancePreflight(plugin));
|
||||
document.getElementById("pluginGovernanceSelfTestBtn")?.addEventListener("click", () => runGovernanceSelfTest(plugin));
|
||||
document.getElementById("pluginGovernanceBenchmarkBtn")?.addEventListener("click", () => recordGovernanceBenchmark(plugin));
|
||||
document.getElementById("pluginGovernanceAdvisoryBtn")?.addEventListener("click", () => createArtifactRevokeAdvisory(plugin));
|
||||
document.getElementById("pluginOperationsLoadBtn")?.addEventListener("click", () => loadPluginOperations(plugin));
|
||||
document.getElementById("pluginOperationsGCDryRunBtn")?.addEventListener("click", () => dryRunOperationsGC(plugin));
|
||||
document.getElementById("pluginDiagnosticBtn")?.addEventListener("click", () => loadDiagnosticPackage(plugin));
|
||||
}
|
||||
|
||||
async function dryRunConfig(plugin: PluginView): Promise<void> {
|
||||
try {
|
||||
const data = await api<DryRunResponse>(`/plugins/${encodeURIComponent(plugin.id)}/config/dry-run`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
artifact_id: plugin.desired_artifact_id,
|
||||
config_json: configEditorValue(),
|
||||
},
|
||||
});
|
||||
el("pluginDryRunResult").textContent = formatJSON(data.result || {});
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig(plugin: PluginView): Promise<void> {
|
||||
try {
|
||||
await api(`/plugins/${encodeURIComponent(plugin.id)}/config`, {
|
||||
method: "PUT",
|
||||
body: {
|
||||
artifact_id: plugin.desired_artifact_id,
|
||||
desired_state: plugin.desired_state,
|
||||
priority: plugin.priority,
|
||||
config_json: configEditorValue(),
|
||||
},
|
||||
});
|
||||
await loadPluginDetail(plugin.id);
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSecret(event: SubmitEvent, plugin: PluginView): Promise<void> {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget as HTMLFormElement;
|
||||
try {
|
||||
await api(`/plugins/${encodeURIComponent(plugin.id)}/secrets`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
name: getFormInput(form, "name").value,
|
||||
artifact_id: plugin.desired_artifact_id,
|
||||
value: getFormInput(form, "value").value,
|
||||
reload_required: getFormInput(form, "reload_required").checked,
|
||||
hot_reload: getFormInput(form, "hot_reload").checked,
|
||||
},
|
||||
});
|
||||
form.reset();
|
||||
await loadPluginDetail(plugin.id);
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function runPluginAction(pluginID: string, action: string): Promise<void> {
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api(`/plugins/${encodeURIComponent(pluginID)}/${action}`, { method: "POST", body: {} });
|
||||
if (action === "delete") {
|
||||
state.selectedPluginID = "";
|
||||
await loadPlugins();
|
||||
} else {
|
||||
await loadPluginDetail(pluginID);
|
||||
}
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function createDesiredFromArtifact(artifact: PluginArtifact): Promise<void> {
|
||||
try {
|
||||
await api(`/plugins/${encodeURIComponent(artifact.plugin_id)}`, {
|
||||
method: "PUT",
|
||||
body: {
|
||||
artifact_id: artifact.id,
|
||||
desired_state: "disabled",
|
||||
priority: Number(getFormInput(el<HTMLFormElement>("artifactDesiredForm"), "priority").value || "100"),
|
||||
config_json: el<HTMLTextAreaElement>("artifactConfigEditor").value || "{}",
|
||||
},
|
||||
});
|
||||
state.selectedArtifactID = "";
|
||||
state.selectedPluginID = artifact.plugin_id;
|
||||
await loadPlugins();
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function rollbackArtifact(pluginID: string, artifactID: string): Promise<void> {
|
||||
try {
|
||||
await api(`/plugins/${encodeURIComponent(pluginID)}/rollback/artifact`, {
|
||||
method: "POST",
|
||||
body: { artifact_id: artifactID },
|
||||
});
|
||||
await loadPluginDetail(pluginID);
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function runBuildAction(pluginID: string, buildID: number, action: string): Promise<void> {
|
||||
if (!buildID || !action) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api(`/plugin-builds/${buildID}/${encodeURIComponent(action)}`, { method: "POST", body: {} });
|
||||
await loadPluginDetail(pluginID);
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function rollbackSnapshot(pluginID: string, snapshotID: number, fullDesired: boolean): Promise<void> {
|
||||
if (!snapshotID) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api(`/plugins/${encodeURIComponent(pluginID)}/rollback/config`, {
|
||||
method: "POST",
|
||||
body: { snapshot_id: snapshotID, full_desired: fullDesired },
|
||||
});
|
||||
await loadPluginDetail(pluginID);
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function showSnapshotDiff(pluginID: string, snapshotID: number): Promise<void> {
|
||||
if (!snapshotID) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await api<SnapshotDiffResponse>(`/plugins/${encodeURIComponent(pluginID)}/config/snapshots/${snapshotID}/diff`);
|
||||
el("pluginDryRunResult").textContent = formatJSON(data.diff || {});
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function createGovernanceReview(plugin: PluginView): Promise<void> {
|
||||
try {
|
||||
await api(`/plugins/${encodeURIComponent(plugin.id)}/governance/review`, {
|
||||
method: "POST",
|
||||
body: { artifact_id: plugin.desired_artifact_id, profile: "prod", decision: "approved" },
|
||||
});
|
||||
await loadPluginDetail(plugin.id);
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function createGovernanceOverride(plugin: PluginView): Promise<void> {
|
||||
const reason = window.prompt("Reason");
|
||||
if (!reason) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api(`/plugins/${encodeURIComponent(plugin.id)}/governance/override`, {
|
||||
method: "POST",
|
||||
body: { artifact_id: plugin.desired_artifact_id, profile: "prod", action: "enable", reason, ttl_seconds: 3600 },
|
||||
});
|
||||
await loadPluginDetail(plugin.id);
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function runGovernancePreflight(plugin: PluginView): Promise<void> {
|
||||
try {
|
||||
const data = await api<Record<string, unknown>>(`/plugins/${encodeURIComponent(plugin.id)}/governance/preflight`, {
|
||||
method: "POST",
|
||||
body: { artifact_id: plugin.desired_artifact_id, config_json: configEditorValue() },
|
||||
});
|
||||
el("pluginDryRunResult").textContent = formatJSON(data);
|
||||
await loadPluginDetail(plugin.id);
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function runGovernanceSelfTest(plugin: PluginView): Promise<void> {
|
||||
try {
|
||||
const data = await api<Record<string, unknown>>(`/plugins/${encodeURIComponent(plugin.id)}/governance/self-test`, {
|
||||
method: "POST",
|
||||
body: { artifact_id: plugin.desired_artifact_id },
|
||||
});
|
||||
el("pluginDryRunResult").textContent = formatJSON(data);
|
||||
await loadPluginDetail(plugin.id);
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function recordGovernanceBenchmark(plugin: PluginView): Promise<void> {
|
||||
const diff = Number(window.prompt("Baseline diff, e.g. 0.25", "0.25"));
|
||||
if (!Number.isFinite(diff)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api(`/plugins/${encodeURIComponent(plugin.id)}/governance/benchmark`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
artifact_id: plugin.desired_artifact_id,
|
||||
profile: "prod",
|
||||
benchmark_profile: "manual",
|
||||
p95_ms: 0,
|
||||
p99_ms: 0,
|
||||
error_rate: 0,
|
||||
active_proxy_capacity: 0,
|
||||
baseline_diff: diff,
|
||||
},
|
||||
});
|
||||
await loadPluginDetail(plugin.id);
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function createArtifactRevokeAdvisory(plugin: PluginView): Promise<void> {
|
||||
const artifact = plugin.desired_artifact;
|
||||
if (!artifact) {
|
||||
return;
|
||||
}
|
||||
const advisoryID = window.prompt("Advisory ID", `local-${shortID(artifact.sha256)}`);
|
||||
if (!advisoryID) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api("/plugin-advisories", {
|
||||
method: "POST",
|
||||
body: {
|
||||
advisory_id: advisoryID,
|
||||
status: "revoked",
|
||||
action: "revoke",
|
||||
artifact_sha256: artifact.sha256,
|
||||
recommended_action: "rollback or upgrade",
|
||||
},
|
||||
});
|
||||
await loadPluginDetail(plugin.id);
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPluginOperations(plugin: PluginView): Promise<void> {
|
||||
try {
|
||||
const data = await api<OperationsResponse>(`/plugins/${encodeURIComponent(plugin.id)}/operations`);
|
||||
el("pluginOperationsOutput").textContent = formatJSON(data.operations || {});
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function dryRunOperationsGC(plugin: PluginView): Promise<void> {
|
||||
try {
|
||||
const data = await api<OperationsResponse>(`/plugins/${encodeURIComponent(plugin.id)}/operations/gc`);
|
||||
el("pluginOperationsOutput").textContent = formatJSON(data);
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDiagnosticPackage(plugin: PluginView): Promise<void> {
|
||||
try {
|
||||
const data = await api<OperationsResponse>(`/plugins/${encodeURIComponent(plugin.id)}/operations/diagnostic`);
|
||||
el("pluginOperationsOutput").textContent = formatJSON(data);
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
function uploadInventoryDetail(): string {
|
||||
const artifact = selectedArtifact();
|
||||
if (!artifact) {
|
||||
return `
|
||||
<div class="plugin-layout">
|
||||
<section class="panel">
|
||||
<h3>Uploaded artifacts</h3>
|
||||
${artifactInventoryList(state.pluginArtifacts)}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h3>Builds</h3>
|
||||
${buildInventoryList(state.pluginBuilds)}
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
const canWrite = isAdmin() && artifact.artifact_type === "binary";
|
||||
return `
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
<h2>${escapeHTML(artifact.plugin_id)}</h2>
|
||||
<p>${escapeHTML(artifact.version)} · ${escapeHTML(artifact.artifact_type)} · ${escapeHTML(shortID(artifact.id))}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="plugin-layout">
|
||||
<section class="panel">
|
||||
<h3>Artifact</h3>
|
||||
<dl class="kv">
|
||||
<dt>Status</dt><dd>${escapeHTML(artifact.status)}</dd>
|
||||
<dt>Runtime</dt><dd>${escapeHTML(artifact.runtime_type || "")}</dd>
|
||||
<dt>Go/API</dt><dd>${escapeHTML(`${artifact.go_version || ""} ${artifact.api_version || ""}`)}</dd>
|
||||
<dt>SHA256</dt><dd>${escapeHTML(artifact.sha256)}</dd>
|
||||
<dt>Extensions</dt><dd>${escapeHTML(jsonList(artifact.extension_points_json))}</dd>
|
||||
<dt>Error</dt><dd>${escapeHTML(artifact.error || "")}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h3>Create desired state</h3>
|
||||
${canWrite ? `
|
||||
<form id="artifactDesiredForm" class="inline-form">
|
||||
<label>Priority<input name="priority" type="number" value="100"></label>
|
||||
<textarea id="artifactConfigEditor">{}</textarea>
|
||||
<button type="submit">Create disabled plugin</button>
|
||||
</form>
|
||||
` : `<div class="empty">Only binary artifacts can be used as plugin desired state.</div>`}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h3>Builds</h3>
|
||||
${buildInventoryList(state.pluginBuilds.filter((build) => build.plugin_id === artifact.plugin_id))}
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function bindInventoryEvents(): void {
|
||||
const artifact = selectedArtifact();
|
||||
const form = document.getElementById("artifactDesiredForm");
|
||||
if (artifact && form instanceof HTMLFormElement) {
|
||||
form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
createDesiredFromArtifact(artifact);
|
||||
});
|
||||
}
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-artifact-detail]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
state.selectedArtifactID = button.dataset.artifactDetail || "";
|
||||
renderPlugins();
|
||||
renderPluginDetail(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function artifactInventoryList(artifacts: PluginArtifact[]): string {
|
||||
if (artifacts.length === 0) {
|
||||
return `<div class="empty">No artifacts</div>`;
|
||||
}
|
||||
return `<div class="mini-list">${artifacts.map((artifact) => `
|
||||
<div class="mini-row">
|
||||
<span>${escapeHTML(artifact.plugin_id)}</span>
|
||||
<span>${escapeHTML(artifact.version)} · ${escapeHTML(artifact.artifact_type)} · ${escapeHTML(artifact.status)}</span>
|
||||
<span>${escapeHTML(shortID(artifact.id))}</span>
|
||||
<button class="secondary" type="button" data-artifact-detail="${escapeAttr(artifact.id)}">Open</button>
|
||||
</div>
|
||||
`).join("")}</div>`;
|
||||
}
|
||||
|
||||
function buildInventoryList(builds: PluginBuild[]): string {
|
||||
if (builds.length === 0) {
|
||||
return `<div class="empty">No builds</div>`;
|
||||
}
|
||||
return `<div class="mini-list">${builds.map((build) => `
|
||||
<div class="mini-row">
|
||||
<span>${escapeHTML(build.plugin_id)} #${escapeHTML(build.id)}</span>
|
||||
<span>${badge(build.status, build.status !== "succeeded")}</span>
|
||||
<span>${escapeHTML(shortID(build.artifact_id || build.source_id))}</span>
|
||||
<span>${escapeHTML(build.log_summary || build.error || "")}</span>
|
||||
</div>
|
||||
`).join("")}</div>`;
|
||||
}
|
||||
|
||||
function selectedPlugin(): PluginView | null {
|
||||
return state.plugins.find((plugin) => plugin.id === state.selectedPluginID) || null;
|
||||
}
|
||||
|
||||
function selectedArtifact(): PluginArtifact | null {
|
||||
return state.pluginArtifacts.find((artifact) => artifact.id === state.selectedArtifactID) || null;
|
||||
}
|
||||
|
||||
function pluginActionButtons(plugin: PluginView): string {
|
||||
return `
|
||||
<button type="button" data-plugin-action="load">Load</button>
|
||||
<button type="button" data-plugin-action="enable">Enable</button>
|
||||
<button class="secondary" type="button" data-plugin-action="disable">Disable</button>
|
||||
<button class="danger" type="button" data-plugin-action="delete">Delete</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function governancePanel(plugin: PluginView, canWrite: boolean): string {
|
||||
if (plugin.governance_error) {
|
||||
return `<div class="alert inline-alert">${escapeHTML(plugin.governance_error)}</div>`;
|
||||
}
|
||||
const governance = plugin.governance;
|
||||
const decision = governance?.decision;
|
||||
const issues = decision?.issues || [];
|
||||
return `
|
||||
<dl class="kv">
|
||||
<dt>Profile</dt><dd>${escapeHTML(decision?.profile || "")}</dd>
|
||||
<dt>Risk</dt><dd>${escapeHTML(decision?.risk_level || "")}</dd>
|
||||
<dt>Policy</dt><dd>${escapeHTML(shortID(decision?.policy_hash || ""))}</dd>
|
||||
<dt>Decision</dt><dd>${badge(decision?.ok ? "allowed" : "blocked", !decision?.ok)}</dd>
|
||||
<dt>Review</dt><dd>${badge(decision?.review_required ? "required" : "not required", Boolean(decision?.review_required))}</dd>
|
||||
<dt>Override</dt><dd>${badge(decision?.warning_override_used ? "used" : "not used", Boolean(decision?.warning_override_used))}</dd>
|
||||
</dl>
|
||||
${issues.length ? `<div class="mini-list">${issues.map((issue) => `
|
||||
<div class="mini-row">
|
||||
<span>${badge(issue.severity, issue.severity !== "info")}</span>
|
||||
<span>${escapeHTML(issue.code)}</span>
|
||||
<span>${escapeHTML(issue.message)}</span>
|
||||
</div>
|
||||
`).join("")}</div>` : `<div class="empty">No governance issues</div>`}
|
||||
${canWrite ? `
|
||||
<div class="row-actions">
|
||||
<button class="secondary" type="button" id="pluginGovernanceReviewBtn">Review</button>
|
||||
<button class="secondary" type="button" id="pluginGovernanceOverrideBtn">Override</button>
|
||||
<button class="secondary" type="button" id="pluginGovernancePreflightBtn">Preflight</button>
|
||||
<button class="secondary" type="button" id="pluginGovernanceSelfTestBtn">Self-test</button>
|
||||
<button class="secondary" type="button" id="pluginGovernanceBenchmarkBtn">Benchmark</button>
|
||||
<button class="danger" type="button" id="pluginGovernanceAdvisoryBtn">Revoke artifact</button>
|
||||
</div>
|
||||
` : ""}
|
||||
<pre class="log-output">${escapeHTML(formatJSON({
|
||||
conflicts: governance?.conflicts,
|
||||
reviews: governance?.reviews || [],
|
||||
warning_overrides: governance?.warning_overrides || [],
|
||||
preflights: governance?.preflights || [],
|
||||
benchmarks: governance?.benchmarks || [],
|
||||
advisories: governance?.advisories || [],
|
||||
}))}</pre>
|
||||
`;
|
||||
}
|
||||
|
||||
function artifactList(artifacts: PluginArtifact[], plugin: PluginView): string {
|
||||
if (artifacts.length === 0) {
|
||||
return `<div class="empty">No artifacts</div>`;
|
||||
}
|
||||
return `<div class="mini-list">${artifacts.map((artifact) => `
|
||||
<div class="mini-row">
|
||||
<span>${escapeHTML(shortID(artifact.id))}</span>
|
||||
<span>${escapeHTML(artifact.version)} · ${escapeHTML(artifact.artifact_type)} · ${escapeHTML(artifact.status)}</span>
|
||||
<span>${escapeHTML(artifact.go_version || "")}</span>
|
||||
${isAdmin() && artifact.artifact_type === "binary" && artifact.id !== plugin.desired_artifact_id ? `<button class="secondary" type="button" data-artifact-rollback="${escapeAttr(artifact.id)}">Rollback</button>` : ""}
|
||||
</div>
|
||||
`).join("")}</div>`;
|
||||
}
|
||||
|
||||
function buildList(builds: PluginBuild[], plugin: PluginView, canWrite: boolean): string {
|
||||
if (builds.length === 0) {
|
||||
return `<div class="empty">No builds</div>`;
|
||||
}
|
||||
return `<div class="mini-list">${builds.map((build) => `
|
||||
<div class="mini-row">
|
||||
<span>#${escapeHTML(build.id)}</span>
|
||||
<span>${badge(build.status, build.status !== "succeeded")}</span>
|
||||
<span>${escapeHTML(shortID(build.artifact_id || build.source_id))}</span>
|
||||
<span>${escapeHTML(build.log_summary || build.error || "")}</span>
|
||||
${canWrite ? buildActions(build, plugin) : ""}
|
||||
</div>
|
||||
`).join("")}</div>`;
|
||||
}
|
||||
|
||||
function buildActions(build: PluginBuild, plugin: PluginView): string {
|
||||
if (build.status === "queued") {
|
||||
return `<button class="secondary" type="button" data-build-id="${escapeAttr(build.id)}" data-build-action="run">Run</button>`;
|
||||
}
|
||||
if (build.status === "running") {
|
||||
return `<button class="secondary" type="button" data-build-id="${escapeAttr(build.id)}" data-build-action="cancel">Cancel</button>`;
|
||||
}
|
||||
if (build.status === "failed" || build.status === "canceled") {
|
||||
return `<button class="secondary" type="button" data-build-id="${escapeAttr(build.id)}" data-build-action="retry">Retry</button>`;
|
||||
}
|
||||
if (build.status === "succeeded" && build.artifact_id && build.artifact_id !== plugin.desired_artifact_id) {
|
||||
return `<button class="secondary" type="button" data-artifact-rollback="${escapeAttr(build.artifact_id)}">Use artifact</button>`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function snapshotList(snapshots: PluginSnapshot[], canWrite: boolean): string {
|
||||
if (snapshots.length === 0) {
|
||||
return `<div class="empty">No snapshots</div>`;
|
||||
}
|
||||
return `<div class="mini-list">${snapshots.map((snapshot) => `
|
||||
<div class="mini-row">
|
||||
<span>#${escapeHTML(snapshot.id)}</span>
|
||||
<span>gen ${escapeHTML(snapshot.desired_generation)} · ${escapeHTML(snapshot.desired_state)} · ${escapeHTML(shortID(snapshot.artifact_id))}</span>
|
||||
<button class="secondary" type="button" data-snapshot-diff="${escapeAttr(snapshot.id)}">Diff</button>
|
||||
${canWrite ? `
|
||||
<button class="secondary" type="button" data-snapshot-rollback="${escapeAttr(snapshot.id)}">Config rollback</button>
|
||||
<button class="secondary" type="button" data-snapshot-full-rollback="${escapeAttr(snapshot.id)}">Full rollback</button>
|
||||
` : ""}
|
||||
</div>
|
||||
`).join("")}</div>`;
|
||||
}
|
||||
|
||||
function proxyConnectionList(connections: PluginProxyConnection[]): string {
|
||||
if (connections.length === 0) {
|
||||
return `<div class="empty">No active proxy connections</div>`;
|
||||
}
|
||||
return `<div class="mini-list">${connections.map((conn) => `
|
||||
<div class="mini-row">
|
||||
<span>#${escapeHTML(conn.id)}</span>
|
||||
<span>${escapeHTML(shortID(conn.artifact_id))} · ${escapeHTML(conn.handler_id)}</span>
|
||||
<span>${escapeHTML(conn.duration_ms)}ms</span>
|
||||
<span>${badge(conn.draining ? "draining" : "active", conn.draining)}</span>
|
||||
</div>
|
||||
`).join("")}</div>`;
|
||||
}
|
||||
|
||||
function secretChip(secret: PluginSecret): string {
|
||||
const reload = secret.reload_required ? "reload required" : "hot reload";
|
||||
return `<span class="chip">${escapeHTML(secret.name)} v${escapeHTML(secret.current_version)} · prev ${escapeHTML(secret.previous_version)} · ${escapeHTML(reload)}</span>`;
|
||||
}
|
||||
|
||||
function detailStat(label: string, value: unknown): string {
|
||||
return `<div class="stat"><span>${escapeHTML(label)}</span><strong>${escapeHTML(String(value ?? ""))}</strong></div>`;
|
||||
}
|
||||
|
||||
function configEditorValue(): string {
|
||||
return el<HTMLTextAreaElement>("pluginConfigEditor").value;
|
||||
}
|
||||
|
||||
function shortID(id?: string): string {
|
||||
return id ? id.slice(0, 12) : "";
|
||||
}
|
||||
|
||||
function prettyJSON(raw: string): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(raw), null, 2);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function jsonList(raw?: string): string {
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const value = JSON.parse(raw);
|
||||
return Array.isArray(value) ? value.join(", ") : String(value);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function formatJSON(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value ?? {}, null, 2);
|
||||
} catch {
|
||||
return String(value ?? "");
|
||||
}
|
||||
}
|
||||
1286
cmd/gateway/admin_plugin_handlers.go
Normal file
1286
cmd/gateway/admin_plugin_handlers.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,11 +4,13 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/tursom/mc-gateway/internal/adminconfig"
|
||||
"github.com/tursom/mc-gateway/internal/admindb"
|
||||
"github.com/tursom/mc-gateway/internal/adminservice"
|
||||
"github.com/tursom/mc-gateway/internal/pluginmanager"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -44,6 +46,7 @@ var (
|
||||
|
||||
adminDB *sql.DB
|
||||
adminDBPath string
|
||||
pluginsManager *pluginmanager.Manager
|
||||
processStartAt = time.Now()
|
||||
)
|
||||
|
||||
@@ -77,7 +80,17 @@ func initializeGatewayRuntime() error {
|
||||
if err := ensureInitialAdminFromEnv(context.Background(), db, os.Getenv(adminEnvInitialPassword)); err != nil {
|
||||
return err
|
||||
}
|
||||
return refreshRouteSnapshot(context.Background())
|
||||
if err := refreshRouteSnapshot(context.Background()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pluginsManager = pluginmanager.New(pluginmanager.Options{
|
||||
DB: db,
|
||||
ArtifactRoot: filepath.Join(filepath.Dir(startup.DBPath), "plugins", "artifacts"),
|
||||
HandleConn: handleRequest,
|
||||
WaitGroup: &exitWaitGroup,
|
||||
})
|
||||
return pluginsManager.Reconcile(context.Background())
|
||||
}
|
||||
|
||||
func closeGatewayRuntime() {
|
||||
|
||||
@@ -64,8 +64,23 @@ button.danger:hover {
|
||||
background: #8f1d15;
|
||||
}
|
||||
|
||||
button.link-button {
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--accent-dark);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
button.link-button:hover {
|
||||
background: transparent;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
border: 1px solid var(--line);
|
||||
@@ -75,6 +90,13 @@ select {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 220px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
@@ -276,6 +298,10 @@ tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
tr.selected td {
|
||||
background: #f0f7f3;
|
||||
}
|
||||
|
||||
.actions {
|
||||
width: 180px;
|
||||
}
|
||||
@@ -320,11 +346,122 @@ tr:last-child td {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.service form {
|
||||
.service form,
|
||||
.inline-form {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.file-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 36px;
|
||||
border-radius: 6px;
|
||||
padding: 0 14px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-button input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.plugin-detail {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.detail-header h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.detail-header p {
|
||||
margin: 2px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status-grid.dense {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.plugin-layout {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plugin-layout .panel {
|
||||
min-width: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.plugin-layout h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: 120px minmax(0, 1fr);
|
||||
gap: 8px 10px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.kv dt {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.kv dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.mini-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mini-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(90px, 0.8fr) minmax(160px, 1.5fr) minmax(100px, 1fr) auto auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.mini-row:last-child {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.log-output {
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #fbfcfb;
|
||||
padding: 10px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.inline-alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
dialog {
|
||||
width: min(460px, calc(100vw - 28px));
|
||||
border: 1px solid var(--line);
|
||||
@@ -361,7 +498,17 @@ dialog h2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.status-grid.dense,
|
||||
.plugin-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.mini-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.detail-header,
|
||||
.toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
|
||||
<nav class="tabs">
|
||||
<button data-tab="routes" class="active" type="button" data-i18n="routes">Routes</button>
|
||||
<button data-tab="plugins" type="button" data-i18n="plugins">Plugins</button>
|
||||
<button data-tab="services" type="button" data-i18n="services">Services</button>
|
||||
<button data-tab="users" type="button" data-i18n="users">Users</button>
|
||||
<button data-tab="metrics" type="button" data-i18n="metrics">Metrics</button>
|
||||
@@ -90,6 +91,38 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="pluginsTab" class="tab-panel hidden">
|
||||
<div class="toolbar">
|
||||
<label class="file-button">
|
||||
<span data-i18n="uploadPlugin">Upload plugin</span>
|
||||
<input id="pluginUploadInput" type="file" accept=".mcgp">
|
||||
</label>
|
||||
<button id="refreshPluginsBtn" class="secondary" type="button" data-i18n="refresh">Refresh</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="pluginID">Plugin ID</th>
|
||||
<th data-i18n="name">Name</th>
|
||||
<th data-i18n="version">Version</th>
|
||||
<th data-i18n="artifact">Artifact</th>
|
||||
<th data-i18n="runtime">Runtime</th>
|
||||
<th data-i18n="desired">Desired</th>
|
||||
<th data-i18n="active">Active</th>
|
||||
<th data-i18n="desiredArtifact">Desired artifact</th>
|
||||
<th data-i18n="extensions">Extensions</th>
|
||||
<th data-i18n="priority">Priority</th>
|
||||
<th data-i18n="restart">Restart</th>
|
||||
<th data-i18n="health">Health</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pluginsBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="pluginDetail" class="plugin-detail"></div>
|
||||
</section>
|
||||
|
||||
<section id="servicesTab" class="tab-panel hidden">
|
||||
<div id="servicesGrid" class="service-grid"></div>
|
||||
</section>
|
||||
|
||||
@@ -2,10 +2,16 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tursom/mc-gateway/internal/pluginmanager"
|
||||
"github.com/tursom/mc-gateway/plugin/api"
|
||||
"github.com/tursom/mc-gateway/protocol"
|
||||
)
|
||||
|
||||
func TestHandleRequestProxiesAndClosesConnections(t *testing.T) {
|
||||
@@ -42,6 +48,209 @@ func TestHandleRequestProxiesAndClosesConnections(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestProtocolProxyReplaysInitialDataOnce(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
packet := gatewayTestPacket("play.example", 0x63, 0x02)
|
||||
source := newGatewayTestConn(packet)
|
||||
setGatewayTestRoutes(map[string]string{
|
||||
"play.example": "backend.example:25565",
|
||||
})
|
||||
pluginsManager = pluginmanager.New(pluginmanager.Options{
|
||||
DB: newGatewayTestPluginDB(t),
|
||||
ArtifactRoot: t.TempDir(),
|
||||
Adapter: gatewayTestPluginAdapter{handler: func(req api.UpstreamConnectRequest) (net.Conn, error) {
|
||||
if req.ServerHost != "play.example" || req.Host != "play.example" {
|
||||
t.Fatalf("request host = %q/%q, want play.example", req.ServerHost, req.Host)
|
||||
}
|
||||
if req.ProtocolVersion != 0x63 || req.NextState != 0x02 {
|
||||
t.Fatalf("protocol/next state = %d/%d, want 99/2", req.ProtocolVersion, req.NextState)
|
||||
}
|
||||
if !bytes.Equal(req.InitialData, packet) {
|
||||
t.Fatalf("initial data = %v, want %v", req.InitialData, packet)
|
||||
}
|
||||
gatewayEnd, pluginEnd := net.Pipe()
|
||||
go func() {
|
||||
defer pluginEnd.Close()
|
||||
buf := make([]byte, len(packet))
|
||||
if _, err := io.ReadFull(pluginEnd, buf); err != nil {
|
||||
t.Errorf("plugin endpoint ReadFull() error = %v", err)
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(buf, packet) {
|
||||
t.Errorf("plugin endpoint initial data = %v, want %v", buf, packet)
|
||||
return
|
||||
}
|
||||
if host := protocol.GetMcHost(buf); host != "play.example" {
|
||||
t.Errorf("plugin endpoint host = %q, want play.example", host)
|
||||
return
|
||||
}
|
||||
_, _ = pluginEnd.Write([]byte("login rejected"))
|
||||
}()
|
||||
return gatewayEnd, nil
|
||||
}},
|
||||
})
|
||||
artifact := uploadGatewayTestArtifactWithCapabilities(t, pluginsManager, "proxy-plugin", gatewayProtocolProxyCapabilities())
|
||||
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "proxy-plugin", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
approveGatewayPluginGovernanceForTest(t, "proxy-plugin", artifact.ID)
|
||||
if _, err := pluginsManager.Enable(context.Background(), "admin", "proxy-plugin"); err != nil {
|
||||
t.Fatalf("Enable() error = %v", err)
|
||||
}
|
||||
|
||||
handleRequest(source)
|
||||
|
||||
if got := source.writeBuf.String(); got != "login rejected" {
|
||||
t.Fatalf("source response = %q, want login rejected", got)
|
||||
}
|
||||
plan := pluginsManager.DispatchPlan(context.Background())
|
||||
if len(plan.Handlers) != 1 {
|
||||
t.Fatalf("dispatch handlers = %d, want 1", len(plan.Handlers))
|
||||
}
|
||||
if plan.Handlers[0].Mode != pluginmanager.UpstreamModeProtocolProxy {
|
||||
t.Fatalf("handler mode = %q, want protocol-proxy", plan.Handlers[0].Mode)
|
||||
}
|
||||
if plan.Handlers[0].ProxyStarted != 1 || plan.Handlers[0].ProxyCompleted != 1 {
|
||||
t.Fatalf("proxy lifecycle = started %d completed %d, want 1/1", plan.Handlers[0].ProxyStarted, plan.Handlers[0].ProxyCompleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestManagedErrBlockedClosesSource(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
source := newGatewayTestConn(gatewayTestPacket("play.example"))
|
||||
setGatewayTestRoutes(map[string]string{
|
||||
"play.example": "backend.example:25565",
|
||||
})
|
||||
pluginsManager = pluginmanager.New(pluginmanager.Options{
|
||||
DB: newGatewayTestPluginDB(t),
|
||||
ArtifactRoot: t.TempDir(),
|
||||
Adapter: gatewayTestPluginAdapter{handler: func(api.UpstreamConnectRequest) (net.Conn, error) {
|
||||
return nil, api.ErrBlocked
|
||||
}},
|
||||
})
|
||||
artifact := uploadGatewayTestArtifact(t, pluginsManager, "blocked-plugin")
|
||||
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "blocked-plugin", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
if _, err := pluginsManager.Enable(context.Background(), "admin", "blocked-plugin"); err != nil {
|
||||
t.Fatalf("Enable() error = %v", err)
|
||||
}
|
||||
|
||||
handleRequest(source)
|
||||
|
||||
if !source.closed {
|
||||
t.Fatal("source was not closed")
|
||||
}
|
||||
if source.writeBuf.Len() != 0 {
|
||||
t.Fatalf("source response length = %d, want 0", source.writeBuf.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestProtocolProxyPanicOnlyFailsCurrentConnection(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
setGatewayTestRoutes(map[string]string{
|
||||
"play.example": "backend.example:25565",
|
||||
})
|
||||
calls := 0
|
||||
pluginsManager = pluginmanager.New(pluginmanager.Options{
|
||||
DB: newGatewayTestPluginDB(t),
|
||||
ArtifactRoot: t.TempDir(),
|
||||
Adapter: gatewayTestPluginAdapter{handler: func(api.UpstreamConnectRequest) (net.Conn, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
panic("boom")
|
||||
}
|
||||
upstream := newGatewayTestConn(nil)
|
||||
upstream.writeBuf.WriteString("ok")
|
||||
return upstream, nil
|
||||
}},
|
||||
})
|
||||
artifact := uploadGatewayTestArtifact(t, pluginsManager, "panic-plugin")
|
||||
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "panic-plugin", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
if _, err := pluginsManager.Enable(context.Background(), "admin", "panic-plugin"); err != nil {
|
||||
t.Fatalf("Enable() error = %v", err)
|
||||
}
|
||||
|
||||
first := newGatewayTestConn(gatewayTestPacket("play.example"))
|
||||
handleRequest(first)
|
||||
if !first.closed {
|
||||
t.Fatal("first connection was not closed")
|
||||
}
|
||||
|
||||
second := newGatewayTestConn(gatewayTestPacket("play.example"))
|
||||
handleRequest(second)
|
||||
if !second.closed {
|
||||
t.Fatal("second connection was not closed")
|
||||
}
|
||||
plan := pluginsManager.DispatchPlan(context.Background())
|
||||
if got := plan.Handlers[0].Panics; got != 1 {
|
||||
t.Fatalf("panic count = %d, want 1", got)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("handler calls = %d, want 2", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestProtocolProxyDisableSkipsNewConnections(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
setGatewayTestRoutes(map[string]string{
|
||||
"play.example": "backend.example:25565",
|
||||
})
|
||||
proxyCalls := 0
|
||||
pluginsManager = pluginmanager.New(pluginmanager.Options{
|
||||
DB: newGatewayTestPluginDB(t),
|
||||
ArtifactRoot: t.TempDir(),
|
||||
Adapter: gatewayTestPluginAdapter{handler: func(req api.UpstreamConnectRequest) (net.Conn, error) {
|
||||
proxyCalls++
|
||||
gatewayEnd, pluginEnd := net.Pipe()
|
||||
initialLen := len(req.InitialData)
|
||||
go func() {
|
||||
defer pluginEnd.Close()
|
||||
_, _ = io.ReadFull(pluginEnd, make([]byte, initialLen))
|
||||
}()
|
||||
return gatewayEnd, nil
|
||||
}},
|
||||
})
|
||||
artifact := uploadGatewayTestArtifactWithCapabilities(t, pluginsManager, "proxy-plugin", gatewayProtocolProxyCapabilities())
|
||||
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "proxy-plugin", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
approveGatewayPluginGovernanceForTest(t, "proxy-plugin", artifact.ID)
|
||||
if _, err := pluginsManager.Enable(context.Background(), "admin", "proxy-plugin"); err != nil {
|
||||
t.Fatalf("Enable() error = %v", err)
|
||||
}
|
||||
|
||||
first := newGatewayTestConn(gatewayTestPacket("play.example"))
|
||||
handleRequest(first)
|
||||
if proxyCalls != 1 {
|
||||
t.Fatalf("proxy calls after first request = %d, want 1", proxyCalls)
|
||||
}
|
||||
if _, err := pluginsManager.Disable(context.Background(), "admin", "proxy-plugin"); err != nil {
|
||||
t.Fatalf("Disable() error = %v", err)
|
||||
}
|
||||
|
||||
legacyUpstream := newGatewayTestConn(nil)
|
||||
registerGatewayUpstreamHook(
|
||||
t,
|
||||
func(net.Conn, string) bool { return true },
|
||||
func(net.Conn, string) (net.Conn, error) { return legacyUpstream, nil },
|
||||
)
|
||||
second := newGatewayTestConn(gatewayTestPacket("play.example"))
|
||||
handleRequest(second)
|
||||
if proxyCalls != 1 {
|
||||
t.Fatalf("proxy calls after disable = %d, want still 1", proxyCalls)
|
||||
}
|
||||
if legacyUpstream.writeBuf.Len() == 0 {
|
||||
t.Fatal("legacy upstream did not receive second request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestRecoversAndClosesConnection(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
@@ -11,6 +16,10 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
if handled, code := runPluginCLI(os.Args[1:]); handled {
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
if err := loadConfig(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -98,46 +107,75 @@ func mapToHost(conn net.Conn) net.Conn {
|
||||
return nil
|
||||
}
|
||||
|
||||
mcHost := protocol.GetMcHost(buf[:n])
|
||||
if mcHost == "" {
|
||||
initialData := append([]byte(nil), buf[:n]...)
|
||||
handshake := protocol.ParseHandshake(initialData)
|
||||
if handshake.ServerHost == "" {
|
||||
log.Err(errEmptyBuffer).
|
||||
Str("client", conn.RemoteAddr().String()).
|
||||
Msg("failed to parse mc host from buffer")
|
||||
return nil
|
||||
}
|
||||
|
||||
host, ok := lookupRoute(mcHost)
|
||||
host, ok := lookupRoute(handshake.ServerHost)
|
||||
if host == "" {
|
||||
gatewayMetrics.RouteMiss()
|
||||
log.Err(errEmptyBuffer).
|
||||
Str("client", conn.RemoteAddr().String()).
|
||||
Str("host", mcHost).
|
||||
Str("host", handshake.ServerHost).
|
||||
Msg("failed to route host")
|
||||
return nil
|
||||
}
|
||||
if ok {
|
||||
gatewayMetrics.RouteHit(mcHost)
|
||||
gatewayMetrics.RouteHit(handshake.ServerHost)
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("client", conn.RemoteAddr().String()).
|
||||
Str("host", mcHost).
|
||||
Str("host", handshake.ServerHost).
|
||||
Str("mc", host).
|
||||
Msg("map to host")
|
||||
|
||||
var client net.Conn
|
||||
|
||||
ok, err = invokeFirstHookHandler(api.HookUpstream, Handler2[net.Conn, string, bool](conn, host), func(handler func(net.Conn, string) (net.Conn, error)) error {
|
||||
var err error
|
||||
client, err = handler(conn, host)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to invoke upstream hook")
|
||||
return nil
|
||||
if pluginsManager != nil {
|
||||
req := newUpstreamConnectRequest(conn, host, handshake, initialData, ok)
|
||||
result, err := pluginsManager.ConnectUpstream(context.Background(), req)
|
||||
if err != nil {
|
||||
if errors.Is(err, api.ErrBlocked) {
|
||||
log.Info().
|
||||
Str("client", conn.RemoteAddr().String()).
|
||||
Str("host", handshake.ServerHost).
|
||||
Msg("managed upstream plugin blocked connection")
|
||||
return nil
|
||||
}
|
||||
log.Err(err).
|
||||
Str("client", conn.RemoteAddr().String()).
|
||||
Str("host", handshake.ServerHost).
|
||||
Str("mc", host).
|
||||
Msg("failed to invoke managed upstream plugin")
|
||||
return nil
|
||||
}
|
||||
if result.Handled {
|
||||
if result.Proxied {
|
||||
return nil
|
||||
}
|
||||
client = result.Conn
|
||||
}
|
||||
}
|
||||
|
||||
if !ok {
|
||||
if client == nil {
|
||||
ok, err = invokeFirstHookHandler(api.HookUpstream, Handler2[net.Conn, string, bool](conn, host), func(handler func(net.Conn, string) (net.Conn, error)) error {
|
||||
var err error
|
||||
client, err = handler(conn, host)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to invoke upstream hook")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if client == nil {
|
||||
target := upstreamtarget.Parse(host)
|
||||
switch target.Protocol {
|
||||
case upstreamtarget.ProtocolQUIC:
|
||||
@@ -154,10 +192,10 @@ func mapToHost(conn net.Conn) net.Conn {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := writeAll(client, buf[:n]); err != nil {
|
||||
if err := writeAll(client, initialData); err != nil {
|
||||
log.Err(err).
|
||||
Str("client", conn.RemoteAddr().String()).
|
||||
Str("host", mcHost).
|
||||
Str("host", handshake.ServerHost).
|
||||
Str("mc", host).
|
||||
Msg("failed to write initial packet to upstream")
|
||||
client.Close()
|
||||
@@ -166,3 +204,63 @@ func mapToHost(conn net.Conn) net.Conn {
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
func newUpstreamConnectRequest(conn net.Conn, upstream string, handshake protocol.Handshake, initialData []byte, routeHit bool) api.UpstreamConnectRequest {
|
||||
target := upstreamtarget.Parse(upstream)
|
||||
transport, serviceName, listenerPort := connectionIngress(conn)
|
||||
req := api.UpstreamConnectRequest{
|
||||
Source: conn,
|
||||
Host: handshake.ServerHost,
|
||||
Upstream: upstream,
|
||||
InitialData: append([]byte(nil), initialData...),
|
||||
Metadata: map[string]string{"route_hit": boolString(routeHit)},
|
||||
ConnectionID: randomHexID(8),
|
||||
TraceID: randomHexID(16),
|
||||
SourceAddr: conn.RemoteAddr().String(),
|
||||
ServerHost: handshake.ServerHost,
|
||||
RawServerHost: handshake.RawServerHost,
|
||||
ProtocolVersion: handshake.ProtocolVersion,
|
||||
NextState: handshake.NextState,
|
||||
RouteID: handshake.ServerHost,
|
||||
RouteTags: []string{},
|
||||
UpstreamRaw: upstream,
|
||||
UpstreamProtocol: string(target.Protocol),
|
||||
UpstreamAddress: target.Address,
|
||||
Transport: transport,
|
||||
ServiceName: serviceName,
|
||||
ListenerPort: listenerPort,
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func connectionIngress(conn net.Conn) (transport string, serviceName string, listenerPort int) {
|
||||
transport = "tcp"
|
||||
serviceName = serviceNameTCPAdmin
|
||||
switch conn.(type) {
|
||||
case *webSocketConn:
|
||||
transport = "websocket"
|
||||
serviceName = serviceNameWebSocket
|
||||
case quicConn:
|
||||
transport = "quic"
|
||||
serviceName = serviceNameQUIC
|
||||
}
|
||||
if addr, ok := conn.LocalAddr().(*net.TCPAddr); ok {
|
||||
listenerPort = addr.Port
|
||||
}
|
||||
return transport, serviceName, listenerPort
|
||||
}
|
||||
|
||||
func boolString(value bool) string {
|
||||
if value {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
|
||||
func randomHexID(size int) string {
|
||||
buf := make([]byte, size)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return hex.EncodeToString([]byte("fallback"))
|
||||
}
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/tursom/mc-gateway/internal/admindb"
|
||||
"github.com/tursom/mc-gateway/internal/pluginmanager"
|
||||
"github.com/tursom/mc-gateway/plugin/api"
|
||||
)
|
||||
|
||||
func TestMapToHostUsesManagedPluginBeforeLegacyHook(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
packet := gatewayTestPacket("play.example")
|
||||
source := newGatewayTestConn(packet)
|
||||
managedUpstream := newGatewayTestConn(nil)
|
||||
legacyUpstream := newGatewayTestConn(nil)
|
||||
setGatewayTestRoutes(map[string]string{
|
||||
"play.example": "backend.example:25565",
|
||||
})
|
||||
pluginsManager = pluginmanager.New(pluginmanager.Options{
|
||||
DB: newGatewayTestPluginDB(t),
|
||||
ArtifactRoot: t.TempDir(),
|
||||
Adapter: gatewayTestPluginAdapter{handler: func(req api.UpstreamConnectRequest) (net.Conn, error) {
|
||||
if req.Host != "play.example" || req.Upstream != "backend.example:25565" || !bytes.Equal(req.InitialData, packet) {
|
||||
t.Fatalf("managed request = %+v, initial=%v", req, req.InitialData)
|
||||
}
|
||||
return managedUpstream, nil
|
||||
}},
|
||||
})
|
||||
artifact := uploadGatewayTestArtifact(t, pluginsManager, "managed-upstream")
|
||||
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "managed-upstream", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
if _, err := pluginsManager.Enable(context.Background(), "admin", "managed-upstream"); err != nil {
|
||||
t.Fatalf("Enable() error = %v", err)
|
||||
}
|
||||
registerGatewayUpstreamHook(
|
||||
t,
|
||||
func(net.Conn, string) bool { return true },
|
||||
func(net.Conn, string) (net.Conn, error) { return legacyUpstream, nil },
|
||||
)
|
||||
|
||||
got := mapToHost(source)
|
||||
if got != managedUpstream {
|
||||
t.Fatalf("mapToHost() = %v, want managed upstream", got)
|
||||
}
|
||||
if !bytes.Equal(managedUpstream.writeBuf.Bytes(), packet) {
|
||||
t.Fatalf("managed upstream initial packet = %v, want %v", managedUpstream.writeBuf.Bytes(), packet)
|
||||
}
|
||||
if legacyUpstream.writeBuf.Len() != 0 {
|
||||
t.Fatalf("legacy upstream was used: %v", legacyUpstream.writeBuf.Bytes())
|
||||
}
|
||||
|
||||
if _, err := pluginsManager.Disable(context.Background(), "admin", "managed-upstream"); err != nil {
|
||||
t.Fatalf("Disable() error = %v", err)
|
||||
}
|
||||
nextSource := newGatewayTestConn(packet)
|
||||
if got := mapToHost(nextSource); got != legacyUpstream {
|
||||
t.Fatalf("mapToHost() after disable = %v, want legacy upstream", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapToHostRoutesThroughHookAndForwardsInitialPacket(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
@@ -161,3 +225,156 @@ func TestMapToHostClosesUpstreamWhenInitialWriteFails(t *testing.T) {
|
||||
t.Fatal("upstream was not closed after write failure")
|
||||
}
|
||||
}
|
||||
|
||||
type gatewayTestPluginAdapter struct {
|
||||
handler api.UpstreamConnectHandler
|
||||
}
|
||||
|
||||
func (a gatewayTestPluginAdapter) Load(_ context.Context, _ pluginmanager.ArtifactRecord, _ pluginmanager.PluginRecord, gateway *pluginmanager.Gateway) (api.Plugin, error) {
|
||||
handler := a.handler
|
||||
if handler == nil {
|
||||
handler = func(api.UpstreamConnectRequest) (net.Conn, error) {
|
||||
return nil, api.ErrPass
|
||||
}
|
||||
}
|
||||
if err := api.RegisterHookHandler(
|
||||
gateway,
|
||||
api.HookUpstreamConnect,
|
||||
func(api.UpstreamConnectRequest) bool { return true },
|
||||
handler,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &gatewayPluginStub{}, nil
|
||||
}
|
||||
|
||||
func newGatewayTestPluginDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := admindb.Open(filepath.Join(t.TempDir(), "gateway.sqlite3"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := admindb.Migrate(db); err != nil {
|
||||
t.Fatalf("Migrate() error = %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func uploadGatewayTestArtifact(t *testing.T, manager *pluginmanager.Manager, pluginID string) pluginmanager.ArtifactRecord {
|
||||
t.Helper()
|
||||
return uploadGatewayTestArtifactWithCapabilities(t, manager, pluginID, "")
|
||||
}
|
||||
|
||||
func uploadGatewayTestArtifactWithCapabilities(t *testing.T, manager *pluginmanager.Manager, pluginID string, capabilities string) pluginmanager.ArtifactRecord {
|
||||
t.Helper()
|
||||
artifact, err := manager.UploadArtifact(context.Background(), pluginmanager.ArtifactUpload{
|
||||
SourcePath: writeGatewayTestMCGPWithCapabilities(t, pluginID, capabilities),
|
||||
FileName: pluginID + ".mcgp",
|
||||
Actor: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UploadArtifact() error = %v", err)
|
||||
}
|
||||
return artifact
|
||||
}
|
||||
|
||||
func approveGatewayPluginGovernanceForTest(t *testing.T, pluginID, artifactID string) {
|
||||
t.Helper()
|
||||
if _, err := pluginsManager.CreateReview(context.Background(), "admin", pluginID, pluginmanager.GovernanceReviewRequest{
|
||||
ArtifactID: artifactID,
|
||||
Profile: pluginmanager.PolicyProfileProd,
|
||||
Decision: pluginmanager.ReviewDecisionApproved,
|
||||
Notes: "test approval",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateReview(%s) error = %v", pluginID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeGatewayTestMCGP(t *testing.T, pluginID string) string {
|
||||
return writeGatewayTestMCGPWithCapabilities(t, pluginID, "")
|
||||
}
|
||||
|
||||
func writeGatewayTestMCGPWithCapabilities(t *testing.T, pluginID string, capabilities string) string {
|
||||
t.Helper()
|
||||
entries := map[string][]byte{
|
||||
"manifest.json": gatewayTestManifestWithCapabilities(t, pluginID, capabilities),
|
||||
"plugin.so": []byte("fake plugin bytes " + pluginID),
|
||||
}
|
||||
return writeGatewayTestMCGPEntries(t, entries)
|
||||
}
|
||||
|
||||
func writeGatewayTestMCGPEntries(t *testing.T, entries map[string][]byte) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "plugin.mcgp")
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Create zip error = %v", err)
|
||||
}
|
||||
writer := zip.NewWriter(file)
|
||||
for name, data := range entries {
|
||||
entry, err := writer.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("Create entry error = %v", err)
|
||||
}
|
||||
if _, err := entry.Write(data); err != nil {
|
||||
t.Fatalf("Write entry error = %v", err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("Close zip writer error = %v", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatalf("Close zip file error = %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func gatewayTestManifest(t *testing.T, pluginID string) []byte {
|
||||
return gatewayTestManifestWithCapabilities(t, pluginID, "")
|
||||
}
|
||||
|
||||
func gatewayProtocolProxyCapabilities() string {
|
||||
return `{
|
||||
"upstream_connect":{"mode":"protocol-proxy"},
|
||||
"scope":{"type":"host","values":["play.example"]},
|
||||
"rollout":{"mode":"canary"},
|
||||
"minecraft":{
|
||||
"protocol_versions":{"tested":[767]},
|
||||
"forwarding":{"supported":["none"],"default":"none"}
|
||||
}
|
||||
}`
|
||||
}
|
||||
|
||||
func gatewayTestManifestWithCapabilities(t *testing.T, pluginID string, capabilities string) []byte {
|
||||
t.Helper()
|
||||
if capabilities == "" {
|
||||
capabilities = `{"extension_points":["upstream.connect/v1"]}`
|
||||
}
|
||||
manifest := pluginmanager.Manifest{
|
||||
SchemaVersion: pluginmanager.SchemaVersion,
|
||||
ID: pluginID,
|
||||
Name: "Managed Upstream",
|
||||
Version: "0.1.0",
|
||||
ArtifactType: pluginmanager.ArtifactTypeBinary,
|
||||
Runtime: pluginmanager.RuntimeManifest{
|
||||
Type: pluginmanager.RuntimeGoPlugin,
|
||||
Entry: pluginmanager.RuntimeEntry,
|
||||
EntrySymbol: "Plugin",
|
||||
},
|
||||
APIVersion: pluginmanager.APIVersion,
|
||||
GoVersion: runtime.Version(),
|
||||
GOOS: runtime.GOOS,
|
||||
GOARCH: runtime.GOARCH,
|
||||
ExtensionPoints: []pluginmanager.ExtensionPoint{{
|
||||
Type: "hook",
|
||||
Key: pluginmanager.ExtensionUpstreamConnect,
|
||||
}},
|
||||
Capabilities: json.RawMessage(capabilities),
|
||||
}
|
||||
data, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal manifest error = %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"plugin"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/tursom/mc-gateway/plugin/api"
|
||||
@@ -112,11 +115,86 @@ func (g *Gateway) ExitWaitGroup() *sync.WaitGroup {
|
||||
return &exitWaitGroup
|
||||
}
|
||||
|
||||
func (g *Gateway) EmitEvent(ctx context.Context, name string, fields map[string]string) error {
|
||||
_ = ctx
|
||||
_ = name
|
||||
_ = fields
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *Gateway) ObserveMetric(ctx context.Context, name string, value float64, labels map[string]string) error {
|
||||
_ = ctx
|
||||
_ = name
|
||||
_ = value
|
||||
_ = labels
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *Gateway) Logger() api.Logger {
|
||||
return noopPluginLogger{}
|
||||
}
|
||||
|
||||
func (g *Gateway) DataStore() api.DataStore {
|
||||
return noopPluginDataStore{}
|
||||
}
|
||||
|
||||
func (g *Gateway) FileStore() api.FileStore {
|
||||
return noopPluginFileStore{}
|
||||
}
|
||||
|
||||
func (g *Gateway) ExternalClient(name string) api.ExternalClient {
|
||||
_ = name
|
||||
return noopExternalClient{}
|
||||
}
|
||||
|
||||
func (g *Gateway) RegisterBackgroundTask(task api.BackgroundTask) error {
|
||||
_ = task
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestOp implements api.Gateway.
|
||||
func (g *Gateway) TestOp() {
|
||||
panic("unimplemented")
|
||||
}
|
||||
|
||||
type noopPluginLogger struct{}
|
||||
|
||||
func (noopPluginLogger) Debug(context.Context, string, map[string]string) {}
|
||||
func (noopPluginLogger) Info(context.Context, string, map[string]string) {}
|
||||
func (noopPluginLogger) Warn(context.Context, string, map[string]string) {}
|
||||
func (noopPluginLogger) Error(context.Context, string, map[string]string) {}
|
||||
|
||||
type noopPluginDataStore struct{}
|
||||
|
||||
func (noopPluginDataStore) Put(context.Context, api.DataRecord) error { return nil }
|
||||
func (noopPluginDataStore) Get(context.Context, string) (api.DataRecord, error) {
|
||||
return api.DataRecord{}, errors.New("plugin data store is unavailable")
|
||||
}
|
||||
func (noopPluginDataStore) Delete(context.Context, string) error { return nil }
|
||||
|
||||
type noopPluginFileStore struct{}
|
||||
|
||||
func (noopPluginFileStore) ResourcePath(string) (string, error) {
|
||||
return "", errors.New("plugin file store is unavailable")
|
||||
}
|
||||
func (noopPluginFileStore) Write(context.Context, string, string, []byte, string, time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
func (noopPluginFileStore) Read(context.Context, string, string, int64) ([]byte, error) {
|
||||
return nil, errors.New("plugin file store is unavailable")
|
||||
}
|
||||
func (noopPluginFileStore) Delete(context.Context, string, string) error { return nil }
|
||||
|
||||
type noopExternalClient struct{}
|
||||
|
||||
func (noopExternalClient) DoHTTP(context.Context, api.ExternalRequest) (api.ExternalResponse, error) {
|
||||
return api.ExternalResponse{}, errors.New("external client is unavailable")
|
||||
}
|
||||
func (noopExternalClient) DialTCP(context.Context, string, time.Duration) (net.Conn, error) {
|
||||
return nil, errors.New("external client is unavailable")
|
||||
}
|
||||
func (noopExternalClient) HealthCheck(context.Context) error { return nil }
|
||||
|
||||
func Handler1[T1, R any](t1 T1) func(func(T1) R) R {
|
||||
return func(acceptor func(T1) R) R {
|
||||
return acceptor(t1)
|
||||
|
||||
244
cmd/gateway/plugin_cli.go
Normal file
244
cmd/gateway/plugin_cli.go
Normal file
@@ -0,0 +1,244 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/tursom/mc-gateway/internal/admindb"
|
||||
"github.com/tursom/mc-gateway/internal/pluginmanager"
|
||||
)
|
||||
|
||||
func runPluginCLI(args []string) (bool, int) {
|
||||
if len(args) < 2 || args[0] != "plugin" {
|
||||
return false, 0
|
||||
}
|
||||
if len(args) < 3 {
|
||||
fmt.Fprintln(os.Stderr, "usage: gateway plugin inspect|validate|compat|source-validate <artifact.mcgp> | source-build <source.mcgp> [out.mcgp]")
|
||||
return true, 2
|
||||
}
|
||||
|
||||
command, packagePath := args[1], args[2]
|
||||
switch command {
|
||||
case "inspect":
|
||||
manifest, err := readPackageManifest(packagePath)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(manifest); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "validate", "compat":
|
||||
tmpRoot, err := os.MkdirTemp("", "mcgp-cli-*")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
defer os.RemoveAll(tmpRoot)
|
||||
store := pluginmanager.NewArtifactStore(tmpRoot)
|
||||
artifact, err := store.ValidateAndStore(pluginmanager.ArtifactUpload{
|
||||
SourcePath: packagePath,
|
||||
FileName: filepath.Base(packagePath),
|
||||
Actor: "cli",
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "ok plugin=%s version=%s sha256=%s api=%s go=%s %s/%s\n",
|
||||
artifact.PluginID, artifact.Version, artifact.SHA256, artifact.APIVersion, artifact.GoVersion, artifact.GOOS, artifact.GOARCH)
|
||||
return true, 0
|
||||
case "source-validate":
|
||||
tmpRoot, err := os.MkdirTemp("", "mcgp-source-cli-*")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
defer os.RemoveAll(tmpRoot)
|
||||
store := pluginmanager.NewArtifactStore(tmpRoot)
|
||||
source, err := store.ValidateAndStoreSource(pluginmanager.ArtifactUpload{
|
||||
SourcePath: packagePath,
|
||||
FileName: filepath.Base(packagePath),
|
||||
Actor: "cli",
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "ok source plugin=%s version=%s source_sha256=%s api=%s go=%s %s/%s\n",
|
||||
source.PluginID, source.Version, source.SHA256, source.APIVersion, source.GoVersion, source.GOOS, source.GOARCH)
|
||||
return true, 0
|
||||
case "source-build":
|
||||
outPath := ""
|
||||
if len(args) >= 4 {
|
||||
outPath = args[3]
|
||||
}
|
||||
build, out, err := buildSourcePackageForCLI(packagePath, outPath)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "ok build=%d plugin=%s source_sha256=%s artifact_sha256=%s builder=%s go=%s status=%s out=%s\n",
|
||||
build.ID, build.PluginID, build.SourceSHA256, build.ArtifactSHA256, build.BuilderType, build.GoVersion, build.Status, out)
|
||||
return true, 0
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown plugin command %q\n", command)
|
||||
return true, 2
|
||||
}
|
||||
}
|
||||
|
||||
func buildSourcePackageForCLI(packagePath, outPath string) (pluginmanager.BuildRecord, string, error) {
|
||||
tmpRoot, err := os.MkdirTemp("", "mcgp-source-build-cli-*")
|
||||
if err != nil {
|
||||
return pluginmanager.BuildRecord{}, "", err
|
||||
}
|
||||
defer os.RemoveAll(tmpRoot)
|
||||
db, err := openPluginCLIDB(filepath.Join(tmpRoot, "plugins.db"))
|
||||
if err != nil {
|
||||
return pluginmanager.BuildRecord{}, "", err
|
||||
}
|
||||
defer db.Close()
|
||||
manager := pluginmanager.New(pluginmanager.Options{
|
||||
DB: db,
|
||||
ArtifactRoot: filepath.Join(tmpRoot, "artifacts"),
|
||||
})
|
||||
source, err := manager.UploadSource(context.Background(), pluginmanager.ArtifactUpload{
|
||||
SourcePath: packagePath,
|
||||
FileName: filepath.Base(packagePath),
|
||||
Actor: "cli",
|
||||
})
|
||||
if err != nil {
|
||||
return pluginmanager.BuildRecord{}, "", err
|
||||
}
|
||||
builds, err := manager.ListBuilds(context.Background(), source.PluginID)
|
||||
if err != nil {
|
||||
return pluginmanager.BuildRecord{}, "", err
|
||||
}
|
||||
var queued pluginmanager.BuildRecord
|
||||
for _, build := range builds {
|
||||
if build.SourceID == source.ID && build.Status == pluginmanager.BuildStatusQueued {
|
||||
queued = build
|
||||
break
|
||||
}
|
||||
}
|
||||
if queued.ID == 0 {
|
||||
return pluginmanager.BuildRecord{}, "", fmt.Errorf("source upload did not create a queued build")
|
||||
}
|
||||
build, err := manager.RunBuild(context.Background(), "cli", queued.ID)
|
||||
if err != nil {
|
||||
return pluginmanager.BuildRecord{}, "", err
|
||||
}
|
||||
if build.Status != pluginmanager.BuildStatusSucceeded {
|
||||
if build.LogSummary != "" {
|
||||
return build, "", fmt.Errorf("source build status %s: %s\n%s", build.Status, build.Error, build.LogSummary)
|
||||
}
|
||||
return build, "", fmt.Errorf("source build status %s: %s", build.Status, build.Error)
|
||||
}
|
||||
artifact, err := manager.Artifact(context.Background(), build.ArtifactID)
|
||||
if err != nil {
|
||||
return build, "", err
|
||||
}
|
||||
if outPath == "" {
|
||||
outPath = filepath.Join(filepath.Dir(packagePath), artifact.PluginID+"-built.mcgp")
|
||||
}
|
||||
if err := packageBinaryArtifact(artifact, outPath); err != nil {
|
||||
return build, "", err
|
||||
}
|
||||
return build, outPath, nil
|
||||
}
|
||||
|
||||
func packageBinaryArtifact(artifact pluginmanager.ArtifactRecord, outPath string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.Create(outPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
zw := zip.NewWriter(out)
|
||||
for _, entry := range []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{name: "manifest.json", path: filepath.Join(filepath.Dir(artifact.FilePath), "manifest.json")},
|
||||
{name: pluginmanager.RuntimeEntry, path: artifact.FilePath},
|
||||
} {
|
||||
if err := addZipFile(zw, entry.name, entry.path); err != nil {
|
||||
zw.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
func addZipFile(zw *zip.Writer, name, path string) error {
|
||||
writer, err := zw.Create(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
_, err = io.Copy(writer, file)
|
||||
return err
|
||||
}
|
||||
|
||||
func openPluginCLIDB(path string) (*sql.DB, error) {
|
||||
db, err := admindb.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := admindb.Migrate(db); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func readPackageManifest(packagePath string) (pluginmanager.Manifest, error) {
|
||||
reader, err := zip.OpenReader(packagePath)
|
||||
if err != nil {
|
||||
return pluginmanager.Manifest{}, err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
for _, file := range reader.File {
|
||||
if file.Name != "manifest.json" {
|
||||
continue
|
||||
}
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
return pluginmanager.Manifest{}, err
|
||||
}
|
||||
defer rc.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(rc, pluginmanager.DefaultManifestMaxBytes+1))
|
||||
if err != nil {
|
||||
return pluginmanager.Manifest{}, err
|
||||
}
|
||||
if len(data) > pluginmanager.DefaultManifestMaxBytes {
|
||||
return pluginmanager.Manifest{}, fmt.Errorf("manifest.json exceeds %d bytes", pluginmanager.DefaultManifestMaxBytes)
|
||||
}
|
||||
var manifest pluginmanager.Manifest
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
return pluginmanager.Manifest{}, err
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
return pluginmanager.Manifest{}, fmt.Errorf("manifest.json is required")
|
||||
}
|
||||
@@ -28,6 +28,7 @@ func saveGatewayState(t *testing.T) func() {
|
||||
oldAdminStartup := adminStartup
|
||||
oldAdminDB := adminDB
|
||||
oldAdminDBPath := adminDBPath
|
||||
oldPluginsManager := pluginsManager
|
||||
oldAdminSessionManager := adminSessionManager
|
||||
oldRouteSnapshot := routeSnapshot.Clone()
|
||||
oldGatewayMetrics := gatewayMetrics
|
||||
@@ -51,6 +52,7 @@ func saveGatewayState(t *testing.T) func() {
|
||||
}
|
||||
adminDB = nil
|
||||
adminDBPath = ""
|
||||
pluginsManager = nil
|
||||
adminSessionManager = adminsession.NewManager()
|
||||
publishRouteSnapshot(nil)
|
||||
gatewayMetrics = gatewaymetrics.New()
|
||||
@@ -70,6 +72,7 @@ func saveGatewayState(t *testing.T) func() {
|
||||
adminStartup = oldAdminStartup
|
||||
adminDB = oldAdminDB
|
||||
adminDBPath = oldAdminDBPath
|
||||
pluginsManager = oldPluginsManager
|
||||
adminSessionManager = oldAdminSessionManager
|
||||
publishRouteSnapshot(oldRouteSnapshot)
|
||||
gatewayMetrics = oldGatewayMetrics
|
||||
@@ -87,16 +90,24 @@ func setGatewayTestRoutes(routes map[string]string) {
|
||||
}
|
||||
|
||||
func gatewayTestPacket(host string, tail ...byte) []byte {
|
||||
packet := []byte{
|
||||
byte(4 + 1 + len(host) + len(tail)),
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
byte(len(host)),
|
||||
protocolVersion := byte(0x63)
|
||||
nextState := byte(0x02)
|
||||
extra := []byte(nil)
|
||||
if len(tail) > 0 {
|
||||
protocolVersion = tail[0]
|
||||
}
|
||||
packet = append(packet, host...)
|
||||
packet = append(packet, tail...)
|
||||
return packet
|
||||
if len(tail) > 1 {
|
||||
nextState = tail[1]
|
||||
}
|
||||
if len(tail) > 2 {
|
||||
extra = tail[2:]
|
||||
}
|
||||
payload := []byte{0x00, protocolVersion, byte(len(host))}
|
||||
payload = append(payload, host...)
|
||||
payload = append(payload, 0x63, 0xdd, nextState)
|
||||
payload = append(payload, extra...)
|
||||
packet := []byte{byte(len(payload))}
|
||||
return append(packet, payload...)
|
||||
}
|
||||
|
||||
type gatewayTestConn struct {
|
||||
|
||||
85
docs/plugin-implementation-plan.md
Normal file
85
docs/plugin-implementation-plan.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# 插件系统阶段实现计划
|
||||
|
||||
本文以 [plugin-system-design.md](plugin-system-design.md) 作为最终目标设计文档,把插件系统拆分成多个可上线的实现阶段。每个阶段都必须在结束时保持 gateway 当前可用:可以启动、可以回滚、可以排障,且不会要求后续阶段补齐后才能恢复基本能力。
|
||||
|
||||
## 拆分原则
|
||||
|
||||
- 以可用的纵向切片拆分,而不是按数据库、API、UI、SDK 等横向模块拆分。
|
||||
- 每个阶段都必须有明确的启用路径、失败回退路径和最小运维证据。
|
||||
- 默认运行路径保持保守:先稳定 `go-plugin + upstream.connect/v1`,再增加源码包、治理、观测和未来 runtime。
|
||||
- 未来能力必须可关闭、可灰度或只做设计预留,不能破坏前一阶段的可用状态。
|
||||
- `plugin-system-design.md` 是目标状态;阶段文档只描述实现顺序和阶段边界。
|
||||
|
||||
## 阶段总览
|
||||
|
||||
| 阶段 | 文档 | 阶段结束时可用状态 |
|
||||
| --- | --- | --- |
|
||||
| 1 | [Managed Binary Plugin MVP](plugin-implementation-stages/phase-01-managed-binary-mvp.md) | 管理员可以上传二进制 `.mcgp`,通过 Admin API/CLI 加载、启用、禁用、删除可信插件;`upstream.connect/v1` dialer mode 可用 |
|
||||
| 2 | [Protocol Proxy MVP](plugin-implementation-stages/phase-02-protocol-proxy-mvp.md) | `upstream.connect/v1` protocol-proxy mode 可用,插件可以接管 MC 字节流并实现登录代理示例 |
|
||||
| 3 | [Source Package Builder](plugin-implementation-stages/phase-03-source-package-builder.md) | 管理员可以上传 source `.mcgp`,受控 builder 产出可加载 artifact,构建失败不影响当前插件 |
|
||||
| 4 | [Admin UI, Config, Secret, Rollback](plugin-implementation-stages/phase-04-admin-ui-config-secret-rollback.md) | 管理页具备可操作的插件管理闭环,支持配置 schema、secret、配置快照和回滚 |
|
||||
| 5 | [Governance And Release Gates](plugin-implementation-stages/phase-05-governance-release-gates.md) | 生产启用前有准入策略、review、冲突分析、preflight/self-test、性能门禁和安全公告处理 |
|
||||
| 6 | [Observability And Operations](plugin-implementation-stages/phase-06-observability-operations.md) | 插件 metrics、events、trace、日志、诊断、background task、plugin_data 和文件资源治理可用 |
|
||||
| 7 | [Extension Ecosystem](plugin-implementation-stages/phase-07-extension-ecosystem.md) | 在稳定主路径上增加 route/status/provider/event/rule/Admin auth 等扩展点和官方插件能力 |
|
||||
| 8 | [Future Runtimes And Distribution](plugin-implementation-stages/phase-08-future-runtimes-distribution.md) | 可选引入 `go-plugin-process`、sandbox/WASM、ingress service、仓库、签名和构建期增强;默认路径仍可运行 |
|
||||
|
||||
## 功能到阶段映射
|
||||
|
||||
下表用于确认 [plugin-system-design.md](plugin-system-design.md) 中的目标能力已经拆入某个阶段。一个能力可能在早期阶段先实现最小可用版本,后续阶段再补齐治理、UI 或生态扩展。
|
||||
|
||||
| 目标能力 | 阶段 | 拆分说明 |
|
||||
| --- | --- | --- |
|
||||
| `.mcgp` binary artifact、manifest 静态校验、artifact 登记 | 1 | 先支持二进制可信 Go plugin,上传不执行代码 |
|
||||
| Plugin Manager、desired/runtime state、dispatch table、审计 | 1 | 建立正式管理路径,替代探索式 config 插件入口 |
|
||||
| `upstream.connect/v1` dialer mode | 1 | 第一条可用数据路径,覆盖 upstream rewrite、自定义拨号 |
|
||||
| `upstream.connect/v1` protocol-proxy mode 和 `net.Conn` 接管 | 2 | 支持完整 MC 字节流接管、initial data replay、draining |
|
||||
| MC 正版/三方登录插件、forwarding、登录后协议处理 | 2 | 由 protocol-proxy 插件实现,core 不消费认证结果 |
|
||||
| Minecraft capability manifest、protocol smoke fixture | 2 | 支撑管理页展示和后续发布门禁 |
|
||||
| source `.mcgp`、builder、构建 provenance | 3 | 源码包构建成 `plugin.so` 后复用阶段 1/2 加载路径 |
|
||||
| builder 隔离、Go/module/ABI 记录、source/build log GC | 3 | 构建失败不影响 active artifact |
|
||||
| Admin 页面基础管理闭环 | 4 | 上传、构建状态、加载、启用、禁用、删除、回滚 |
|
||||
| 配置 schema、配置快照、配置迁移入口 | 4 | 错误配置不切换 active artifact |
|
||||
| SecretStore、secret version、reload/rotation 基础 | 4 | secret 不在页面、日志、审计中明文展示 |
|
||||
| artifact rollback、config rollback | 4 | 回滚前重新执行当前基础门禁 |
|
||||
| admission policy、review、risk、warning override | 5 | 生产启用前可解释和可审计 |
|
||||
| composition conflict、scope overlap、dispatch plan | 5 | 阻断 protocol-proxy 重叠、provider 单例冲突等 |
|
||||
| preflight/self-test、benchmark release gate | 5 | 高风险插件启用前有证据 |
|
||||
| denylist、quarantine、revoke、安全公告 | 5 | 阻断受影响 artifact 的 enable/rollback |
|
||||
| metrics、custom metrics、business events、trace | 6 | 提供运行时观测和脱敏摘要 |
|
||||
| plugin logger、diagnostic package、Runbook 支撑 | 6 | 插件故障可定位、可降级、可导出摘要 |
|
||||
| background task、ExternalClient、外部依赖治理 | 6 | 周期同步、受控外联、熔断和健康状态 |
|
||||
| PluginDataStore、PluginFileStore、runtime file GC | 6 | 插件私有数据和文件资源受配额/retention 管理 |
|
||||
| route resolver/provider、route decision | 7 | 降低动态路由和外部 CMDB 集成成本 |
|
||||
| status ping、MOTD、维护模式 | 7 | 不必完整 protocol-proxy 即可定制状态响应 |
|
||||
| middleware、provider、event subscriber、rule/policy engine | 7 | 补齐 Hook 之外的生产扩展形态 |
|
||||
| Admin auth provider、外部身份绑定 | 7 | 只影响管理页登录,保留本地 break-glass |
|
||||
| `go-plugin-process` 服务启动模式、进程级卸载、fd/shm 迁移 | 8 | 未来可选,默认 `in-process` 路径仍可运行 |
|
||||
| sandbox-process、WASM、capability enforcement | 8 | 面向隔离、跨语言和轻量规则场景 |
|
||||
| `ingress.service/v1` 自定义入口服务 | 8 | 由 gateway/supervisor 管理 listener,不允许插件任意监听 |
|
||||
| 插件仓库、签名、SBOM 漏洞扫描、license policy | 8 | 仓库只导入本地 artifact,不自动启用 |
|
||||
| build-time instrumentation | 8 | 官方/组织 CI 能力,产物是 gateway binary,不是热加载插件 |
|
||||
| promotion、drift、DR drill | 4-6 | 阶段 4 建立回滚和快照,阶段 5/6 补齐门禁、diff、诊断和演练证据 |
|
||||
|
||||
## 全阶段不变量
|
||||
|
||||
这些规则从阶段 1 开始就不能被破坏:
|
||||
|
||||
- 上传包校验不能执行插件代码。
|
||||
- 生产路径统一以 `.mcgp` artifact 为单位管理。
|
||||
- 插件管理写操作必须有审计日志。
|
||||
- 启用失败不能破坏旧 dispatch table。
|
||||
- 禁用插件后,新连接不能再进入该插件。
|
||||
- 已加载 Go plugin 不能承诺真正热卸载;只能逻辑禁用或未来通过 `go-plugin-process` 退出子进程回收。
|
||||
- MC 正版/三方登录、身份映射、forwarding 和后续协议处理属于 protocol-proxy 插件,不由 gateway core 拼装。
|
||||
- 玩家名、UUID、source IP、secret、token、session response 和 packet payload 默认不进入指标标签、审计明文或普通诊断输出。
|
||||
|
||||
## 阶段推进规则
|
||||
|
||||
进入下一阶段前必须满足:
|
||||
|
||||
- 当前阶段文档中的验收项全部通过。
|
||||
- 已实现能力有最小自动化测试或可重复手动验证步骤。
|
||||
- 失败路径已验证:加载失败、启用失败、禁用、删除、重启恢复。
|
||||
- 文档已更新:用户怎么启用、怎么回滚、怎么排障。
|
||||
|
||||
如果某阶段出现实现复杂度超出预期,允许拆出子阶段,但子阶段也必须保持“当前可用”。
|
||||
125
docs/plugin-implementation-stages/phase-01-managed-binary-mvp.md
Normal file
125
docs/plugin-implementation-stages/phase-01-managed-binary-mvp.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# 阶段 1:Managed Binary Plugin MVP
|
||||
|
||||
## 目标
|
||||
|
||||
交付最小可用的受管理插件系统:管理员可以上传二进制 `.mcgp`,gateway 能校验、登记、加载、启用、禁用和删除可信 Go plugin。第一阶段只要求 `upstream.connect/v1` 的 dialer mode 可用,用于替换上游拨号或实现简单 upstream rewrite。
|
||||
|
||||
本阶段完成后,插件系统已经从探索代码进入 SQLite/Admin 管理路径,但不承诺源码包构建、完整 protocol-proxy、复杂治理和 Admin 完整页面。
|
||||
|
||||
## 可用性检查点
|
||||
|
||||
阶段结束时必须能做到:
|
||||
|
||||
- gateway 无插件时行为不变。
|
||||
- 管理员上传一个二进制 `.mcgp` 后,可以通过 Admin API 或 CLI inspect artifact。
|
||||
- 管理员可以加载并启用 `upstream-rewrite` 示例插件。
|
||||
- 命中插件 scope 的连接走插件返回的 upstream conn;不命中时走原默认 upstream。
|
||||
- 禁用插件后,新连接不再调用该插件。
|
||||
- 插件启用失败或 handler panic 不破坏旧 dispatch table。
|
||||
- 重启后,SQLite 中 enabled 的插件按 priority 恢复。
|
||||
|
||||
## 范围
|
||||
|
||||
### 包和 artifact
|
||||
|
||||
- 支持 `.mcgp` zip 上传。
|
||||
- `artifact_type=binary`。
|
||||
- `runtime.type=go-plugin`。
|
||||
- 包内必须包含 `manifest.json` 和 `plugin.so`。
|
||||
- 上传阶段只解析 zip 和 manifest,不执行插件代码。
|
||||
- 记录 artifact sha256、plugin ID、version、Go version、GOOS/GOARCH、API version、extension points 和 capabilities 摘要。
|
||||
|
||||
### 数据模型
|
||||
|
||||
实现最小表:
|
||||
|
||||
- `plugin_artifacts`
|
||||
- `plugins`
|
||||
- `plugin_operations`
|
||||
- `plugin_config_snapshots`
|
||||
- `audit_logs.metadata_json` 扩展或等价结构化审计字段
|
||||
|
||||
字段必须能表达:
|
||||
|
||||
- artifact 状态:uploaded、validated、loadable、loaded、rejected、deleted。
|
||||
- plugin desired state:enabled、disabled、deleted。
|
||||
- runtime state:not_loaded、loaded、enabled、failed、disabled。
|
||||
- desired generation 和 applied generation。
|
||||
- active artifact、desired artifact、loaded artifact 的差异。
|
||||
|
||||
### Runtime 和 dispatch
|
||||
|
||||
- 新增 Plugin Manager。
|
||||
- 保留现有 `api.Plugin` 和 `Gateway.Hook` 兼容层。
|
||||
- 将现有 `HookUpstream` 收敛为 `upstream.connect/v1` 注册路径。
|
||||
- dispatch table 使用只读快照,更新时整体替换。
|
||||
- handler 排序规则:priority 升序,priority 相同按 plugin ID。
|
||||
- handler 返回 `ErrPass` 时继续后续 handler;返回 `net.Conn` 时停止;返回普通 error 时本次连接失败。
|
||||
- handler 调用必须有 panic recover、timeout 和错误计数。
|
||||
|
||||
### Admin API / CLI
|
||||
|
||||
最小接口:
|
||||
|
||||
- 上传 artifact。
|
||||
- 查看 artifact。
|
||||
- 创建或更新 plugin desired state。
|
||||
- load。
|
||||
- enable。
|
||||
- disable。
|
||||
- delete。
|
||||
- 查看 plugin runtime state。
|
||||
- 查看 dispatch plan 摘要。
|
||||
|
||||
CLI 可以先作为开发工具,覆盖:
|
||||
|
||||
- `plugin inspect`
|
||||
- `plugin validate`
|
||||
- `plugin compat`
|
||||
|
||||
### 示例
|
||||
|
||||
提供 `examples/plugins/upstream-rewrite`:
|
||||
|
||||
- 读取 `match_host` 和 `upstream` 配置。
|
||||
- 注册 `upstream.connect/v1`。
|
||||
- 命中时 `net.Dial` 到 upstream 并返回连接。
|
||||
- 不命中时返回 pass。
|
||||
|
||||
## 明确不做
|
||||
|
||||
- 不支持 source `.mcgp` 构建。
|
||||
- 不支持 protocol-proxy mode。
|
||||
- 不支持 SecretStore。
|
||||
- 不支持完整 Admin 页面。
|
||||
- 不支持准入 review、SBOM、license 策略和仓库。
|
||||
- 不支持真正热卸载。
|
||||
- 不支持 sandbox、WASM 或 `go-plugin-process`。
|
||||
|
||||
## 实现任务
|
||||
|
||||
1. 增加 `.mcgp` 静态校验:zip slip、大小、manifest、runtime entry。
|
||||
2. 增加 manifest schema v1 的最小字段校验。
|
||||
3. 增加 Plugin Manager 和 runtime adapter 抽象,只实现 `go-plugin`。
|
||||
4. 增加 SQLite migration。
|
||||
5. 增加 desired state reconcile。
|
||||
6. 把连接路径接入 dispatch table snapshot。
|
||||
7. 实现 `upstream.connect/v1` dialer mode contract。
|
||||
8. 实现 load/enable/disable/delete API。
|
||||
9. 增加基础审计事件。
|
||||
10. 增加 upstream-rewrite 示例插件。
|
||||
|
||||
## 验收
|
||||
|
||||
- `upstream-rewrite` 能通过 `.mcgp` 上传、load、enable。
|
||||
- 启用后指定 host 连接到新 upstream。
|
||||
- disable 后新连接恢复默认 upstream。
|
||||
- plugin.Open 失败返回稳定错误,不影响其他插件和默认连接路径。
|
||||
- gateway 重启后 enabled 插件恢复。
|
||||
- `git diff --check` 和现有测试通过。
|
||||
|
||||
## 回滚策略
|
||||
|
||||
- 删除或 disable 插件即可恢复默认 upstream。
|
||||
- 如果 artifact 已加载,delete 后标记 pending cleanup,提示重启彻底清理。
|
||||
- 如果 Plugin Manager 初始化失败,gateway 应可在禁用插件系统配置下启动,并保留原有路由能力。
|
||||
108
docs/plugin-implementation-stages/phase-02-protocol-proxy-mvp.md
Normal file
108
docs/plugin-implementation-stages/phase-02-protocol-proxy-mvp.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# 阶段 2:Protocol Proxy MVP
|
||||
|
||||
## 目标
|
||||
|
||||
在阶段 1 的管理和生命周期基础上,让 `upstream.connect/v1` 支持 protocol-proxy mode。插件可以返回自管 `net.Conn`,gateway 将已读取的 initial handshake bytes 回放给该连接,并把后续客户端字节转发给插件 endpoint。
|
||||
|
||||
本阶段使 MC 正版/三方登录插件具备技术可行性:登录、身份映射、forwarding 和登录后的协议处理都由插件完成,gateway core 只负责连接交接和治理。
|
||||
|
||||
阶段 2 实现后,gateway core 不解析 login/encryption/session,不消费插件内部认证结果,也不根据玩家名、UUID、权限或 session 状态改变后续路由。protocol-proxy 插件接管连接后,Minecraft 登录业务完全属于插件;core 只保留 initial data replay、双向 copy、draining、force close 和低基数运行摘要。
|
||||
|
||||
## 可用性检查点
|
||||
|
||||
阶段结束时必须能做到:
|
||||
|
||||
- dialer mode 仍可用。
|
||||
- protocol-proxy 插件可以接管完整 MC 字节流。
|
||||
- 初始 handshake 不丢失、不重复。
|
||||
- protocol-proxy 插件禁用后,新连接不再进入插件;已有连接进入 draining 或按管理员操作 force close。
|
||||
- `mc-auth-proxy` 示例至少能跑通一个登录失败响应或简单 session fixture。
|
||||
|
||||
## 范围
|
||||
|
||||
### `net.Conn` 接管契约
|
||||
|
||||
实现:
|
||||
|
||||
- `UpstreamConnectRequest.InitialData` 复制语义。
|
||||
- returned conn 初始写入 deadline。
|
||||
- 初始写入失败后的关闭和错误记录。
|
||||
- 双向 copy、half-close 退化、字节数统计、耗时统计。
|
||||
- active proxy connection 计数。
|
||||
- protocol-proxy 连接 draining 状态。
|
||||
|
||||
### 请求字段
|
||||
|
||||
补齐 request 字段:
|
||||
|
||||
- connection ID。
|
||||
- trace ID。
|
||||
- source addr。
|
||||
- normalized server host 和 raw server host。
|
||||
- protocol version。
|
||||
- next state。
|
||||
- route ID、route tags、upstream raw/protocol/address。
|
||||
- transport、service name、listener port。
|
||||
|
||||
字段新增必须只追加,不改变阶段 1 语义。
|
||||
|
||||
### 示例插件
|
||||
|
||||
提供 `examples/plugins/mc-auth-proxy` 初版:
|
||||
|
||||
- 注册 `upstream.connect/v1`。
|
||||
- 使用 `net.Pipe` 或等价 endpoint。
|
||||
- 读取 handshake/login start。
|
||||
- 对不支持或 fixture 失败场景返回 login disconnect/kick。
|
||||
- 连接 backend 并做最小透明转发。
|
||||
- 通过事件或日志上报低基数失败原因。
|
||||
|
||||
本阶段不要求完整生产级 Mojang/Yggdrasil 实现,但示例结构必须能承载后续认证源。
|
||||
|
||||
### Minecraft 能力声明
|
||||
|
||||
manifest 支持 `minecraft` 字段:
|
||||
|
||||
- protocol versions。
|
||||
- states。
|
||||
- auth modes。
|
||||
- forwarding supported/default。
|
||||
- unsupported policy。
|
||||
- modded 声明。
|
||||
|
||||
Admin API 可以先展示摘要,不要求完整 UI。
|
||||
|
||||
## 明确不做
|
||||
|
||||
- 不让 gateway core 解析 login/encryption/session。
|
||||
- 不让插件返回 `AuthResult` 给 core。
|
||||
- 不实现 `auth.provider/v1`。
|
||||
- 不做 play 阶段 packet filter。
|
||||
- 不做真实客户端大规模压测门禁。
|
||||
|
||||
## 实现任务
|
||||
|
||||
1. 定义 `UpstreamConnectRequest` 稳定 struct。
|
||||
2. 增加 `ErrPass`、`ErrBlocked` 和普通 error 行为。
|
||||
3. 实现 initial data replay。
|
||||
4. 实现 protocol-proxy connection lifecycle。
|
||||
5. 实现 draining 和 force close API。
|
||||
6. 增加 protocol-proxy metrics。
|
||||
7. 增加 Minecraft capability manifest schema。
|
||||
8. 增加 mc-auth-proxy 示例。
|
||||
9. 增加 protocol smoke test helper。
|
||||
|
||||
## 验收
|
||||
|
||||
- protocol-proxy 示例能读取 gateway 已解析前的完整 handshake bytes。
|
||||
- 初始包只被插件处理一次。
|
||||
- 插件返回不可读 conn 时,连接路径不会永久阻塞。
|
||||
- 插件 panic 只影响当前连接。
|
||||
- disable 后新连接不再进入插件。
|
||||
- 文档明确 MC 登录业务完全属于插件。
|
||||
|
||||
## 回滚策略
|
||||
|
||||
- disable protocol-proxy 插件恢复默认 upstream。
|
||||
- 对已有 protocol-proxy 连接,默认 drain;必要时 force close。
|
||||
- 如果 protocol-proxy 功能引发问题,可以保留阶段 1 dialer mode 插件能力。
|
||||
@@ -0,0 +1,112 @@
|
||||
# 阶段 3:Source Package Builder
|
||||
|
||||
## 目标
|
||||
|
||||
支持 source `.mcgp`。管理员可以上传源码包,由受控 builder 构建出最终 `plugin.so` artifact,再进入阶段 1/2 已经可用的加载和启用流程。
|
||||
|
||||
本阶段解决开发者分发源码包、记录构建环境和产物 provenance 的问题。构建失败不得影响当前 active 插件。
|
||||
|
||||
## 可用性检查点
|
||||
|
||||
阶段结束时必须能做到:
|
||||
|
||||
- 上传 binary `.mcgp` 的路径不受影响。
|
||||
- 上传 source `.mcgp` 后创建 build job。
|
||||
- build 成功后生成新的 binary artifact,可 load/enable。
|
||||
- build 失败只记录错误和日志摘要,不改变当前 active artifact。
|
||||
- 管理员能看到 source sha256、builder、Go version、module list 和 artifact sha256。
|
||||
|
||||
## 范围
|
||||
|
||||
### Source 包格式
|
||||
|
||||
source `.mcgp` 必须包含:
|
||||
|
||||
- `manifest.json`
|
||||
- `go.mod`
|
||||
- build entry
|
||||
- 源码文件
|
||||
|
||||
可选:
|
||||
|
||||
- `go.sum`
|
||||
- `vendor/`
|
||||
- README、LICENSE、SBOM
|
||||
|
||||
不执行包内任意 shell 脚本。构建命令由 gateway/builder 固定生成。
|
||||
|
||||
### Builder
|
||||
|
||||
支持两种 builder:
|
||||
|
||||
- `local-process`:开发模式。
|
||||
- `container`:生产推荐。
|
||||
|
||||
生产默认推荐 container builder 或外部 CI。gateway 主进程不得直接执行 `go build`。
|
||||
|
||||
固定构建维度:
|
||||
|
||||
- Go version。
|
||||
- GOOS/GOARCH/GOAMD64/GOARM64。
|
||||
- CGO。
|
||||
- build tags。
|
||||
- SDK module version。
|
||||
- GOPROXY/GONOSUMDB/GOPRIVATE 策略。
|
||||
- vendor required。
|
||||
|
||||
### Provenance
|
||||
|
||||
记录:
|
||||
|
||||
- source package sha256。
|
||||
- artifact sha256。
|
||||
- builder type/image/version。
|
||||
- Go version。
|
||||
- `go list -m -json all` 摘要。
|
||||
- `go version -m` 摘要。
|
||||
- ABI fingerprint。
|
||||
- build log 摘要。
|
||||
- build start/end/duration。
|
||||
|
||||
### GC
|
||||
|
||||
实现:
|
||||
|
||||
- source package 保留策略。
|
||||
- build log 保留策略。
|
||||
- artifact GC candidate。
|
||||
- active/desired/snapshot referenced artifact 不可被 GC。
|
||||
|
||||
## 明确不做
|
||||
|
||||
- 不把源码构建等同于运行时沙箱。
|
||||
- 不支持自定义构建脚本。
|
||||
- 不强制签名。
|
||||
- 不实现远程插件仓库。
|
||||
|
||||
## 实现任务
|
||||
|
||||
1. 扩展 `.mcgp` 校验支持 `artifact_type=source`。
|
||||
2. 增加 `plugin_builds` 状态机。
|
||||
3. 实现 build operation 和取消/重试。
|
||||
4. 实现 local-process builder。
|
||||
5. 实现 container builder 接口或预留适配。
|
||||
6. 构建后执行 metadata ABI 校验。
|
||||
7. 构建成功写入 `plugin_artifacts`。
|
||||
8. 构建失败保存脱敏日志摘要。
|
||||
9. 增加 build API/CLI。
|
||||
10. 更新示例插件,支持 source package。
|
||||
|
||||
## 验收
|
||||
|
||||
- source upstream-rewrite 能构建并启用。
|
||||
- source mc-auth-proxy 能构建或至少通过编译 fixture。
|
||||
- builder Go version 不匹配时阻断启用或构建。
|
||||
- 构建日志不包含 secret、环境 token 或完整私有路径。
|
||||
- 构建失败不影响 active artifact。
|
||||
|
||||
## 回滚策略
|
||||
|
||||
- 构建产物只有 enable 后才影响流量。
|
||||
- 构建失败或产物校验失败时保留旧 artifact。
|
||||
- 如 builder 配置异常,可关闭 source package 构建,继续支持 binary `.mcgp`。
|
||||
@@ -0,0 +1,110 @@
|
||||
# 阶段 4:Admin UI、配置、Secret 和回滚
|
||||
|
||||
## 目标
|
||||
|
||||
把阶段 1 到 3 的能力做成管理员可用的管理闭环。管理页支持上传、构建状态、加载、启用、禁用、删除、配置编辑、secret 配置、配置快照和 artifact 回滚。
|
||||
|
||||
阶段结束时,普通运维不需要直接调用底层 API 才能完成插件日常管理。
|
||||
|
||||
## 可用性检查点
|
||||
|
||||
阶段结束时必须能做到:
|
||||
|
||||
- 管理员能在页面看到插件列表、artifact、runtime state 和最近错误。
|
||||
- 管理员能上传 binary/source `.mcgp`。
|
||||
- 管理员能编辑配置并执行 dry-run 校验。
|
||||
- 管理员能配置插件 secret ref,不看到 secret 明文。
|
||||
- 管理员能禁用、删除、切换 artifact 和回滚配置快照。
|
||||
- 配置错误不会切换 active artifact。
|
||||
|
||||
## 范围
|
||||
|
||||
### Admin 页面
|
||||
|
||||
列表展示:
|
||||
|
||||
- plugin ID、name、version。
|
||||
- artifact type。
|
||||
- runtime state。
|
||||
- desired state。
|
||||
- active/desired/loaded artifact。
|
||||
- extension points。
|
||||
- priority。
|
||||
- scope/rollout。
|
||||
- restart required。
|
||||
- health/最近错误。
|
||||
|
||||
详情页展示:
|
||||
|
||||
- manifest metadata。
|
||||
- Go/API/ABI 兼容信息。
|
||||
- capabilities 摘要。
|
||||
- Minecraft capability 摘要。
|
||||
- build 历史和日志摘要。
|
||||
- current config。
|
||||
- secret 状态。
|
||||
- dispatch plan。
|
||||
- active proxy connections。
|
||||
|
||||
### 配置
|
||||
|
||||
- 支持 JSON 编辑器兜底。
|
||||
- 支持 JSON Schema 基础校验。
|
||||
- 支持 `ReloadConfig()` dry-run。
|
||||
- 支持 sensitive 字段脱敏 diff。
|
||||
- 支持 config snapshot。
|
||||
- 支持 config-only rollback 和 full desired rollback。
|
||||
|
||||
### Secret
|
||||
|
||||
实现最小 SecretStore:
|
||||
|
||||
- 创建/更新 secret。
|
||||
- secret ref 校验。
|
||||
- 当前/previous version。
|
||||
- reload required/hot reload 标记。
|
||||
- secret 不进入日志、审计明文和 API 响应。
|
||||
|
||||
### 回滚
|
||||
|
||||
支持:
|
||||
|
||||
- artifact rollback。
|
||||
- config snapshot rollback。
|
||||
- rollback 前重新执行兼容性和当前基础门禁。
|
||||
- rollback 写审计。
|
||||
|
||||
## 明确不做
|
||||
|
||||
- 不做完整准入审批。
|
||||
- 不做 SBOM/license 阻断。
|
||||
- 不做外部 KMS。
|
||||
- 不做复杂声明式 UI,自定义 HTML/JS 不支持。
|
||||
- 不做 promotion bundle。
|
||||
|
||||
## 实现任务
|
||||
|
||||
1. 实现插件列表和详情页。
|
||||
2. 实现上传和构建状态 UI。
|
||||
3. 实现配置编辑、schema 校验和 dry-run。
|
||||
4. 实现 secret 状态和编辑流程。
|
||||
5. 实现 artifact rollback UI/API。
|
||||
6. 实现 config snapshot diff/rollback。
|
||||
7. 实现 restart required 展示。
|
||||
8. 实现基础 permission key 映射到 admin/member/guest。
|
||||
9. 所有写操作写审计。
|
||||
|
||||
## 验收
|
||||
|
||||
- 管理员可在 UI 上传并启用 upstream-rewrite。
|
||||
- 管理员可在 UI 上传 source package 并查看 build result。
|
||||
- 修改错误配置不会影响当前运行插件。
|
||||
- secret 在页面和审计里不显示明文。
|
||||
- rollback 到旧 artifact 后新连接使用旧版本。
|
||||
- member 只能查看状态,不能执行写操作。
|
||||
|
||||
## 回滚策略
|
||||
|
||||
- UI 出问题时保留 Admin API/CLI 操作路径。
|
||||
- 配置保存失败不改变 desired generation。
|
||||
- 回滚失败不改变当前 active state。
|
||||
@@ -0,0 +1,116 @@
|
||||
# 阶段 5:Governance And Release Gates
|
||||
|
||||
## 目标
|
||||
|
||||
把插件从“能运行”提升到“可安全进入生产”。本阶段实现准入策略、review、风险分级、冲突分析、发布门禁、preflight/self-test、benchmark 结果和安全公告响应。
|
||||
|
||||
阶段结束后,管理员可以解释一个插件为什么能启用、为什么被阻断、启用会影响哪些流量,以及如何回滚。
|
||||
|
||||
## 可用性检查点
|
||||
|
||||
阶段结束时必须能做到:
|
||||
|
||||
- 高风险插件启用前需要 review。
|
||||
- protocol-proxy scope 重叠会阻断启用。
|
||||
- 必需 secret、依赖、feature 缺失会阻断启用。
|
||||
- preflight/self-test 失败会阻断或进入 warning。
|
||||
- benchmark 结果超过阈值会进入 warning/blocking。
|
||||
- denylist/advisory 命中后不能 rollback 到受影响 artifact。
|
||||
|
||||
## 范围
|
||||
|
||||
### 准入策略
|
||||
|
||||
实现:
|
||||
|
||||
- dev/staging/prod profile。
|
||||
- risk level。
|
||||
- policy snapshot hash。
|
||||
- warning override TTL。
|
||||
- review 记录绑定 artifact/config/scope/rollout/runtime limits/features/policy hash。
|
||||
- denylist、quarantine、revoke。
|
||||
|
||||
### 冲突分析
|
||||
|
||||
实现:
|
||||
|
||||
- scope overlap。
|
||||
- protocol-proxy singleton 冲突。
|
||||
- provider singleton 冲突。
|
||||
- middleware ordering cycle。
|
||||
- shadowed handler warning。
|
||||
- dispatch plan API/UI。
|
||||
|
||||
### Preflight 和 SelfTest
|
||||
|
||||
实现通用门禁:
|
||||
|
||||
- config。
|
||||
- secret。
|
||||
- external dependency 声明。
|
||||
- runtime limits。
|
||||
- scope/rollout。
|
||||
- Minecraft capability。
|
||||
- backend forwarding warning。
|
||||
|
||||
插件实现 `PreflightChecker` 或 `SelfTester` 时复用结果。
|
||||
|
||||
### 性能门禁
|
||||
|
||||
记录:
|
||||
|
||||
- benchmark profile。
|
||||
- P95/P99。
|
||||
- error rate。
|
||||
- active proxy capacity。
|
||||
- baseline diff。
|
||||
|
||||
默认策略:
|
||||
|
||||
- 退化超过 20% warning。
|
||||
- 退化超过 50% 或超过 runtime limit blocking。
|
||||
|
||||
### 安全公告
|
||||
|
||||
支持本地 advisory:
|
||||
|
||||
- artifact sha256 match。
|
||||
- plugin/version range match。
|
||||
- SBOM dependency match。
|
||||
- recommended action。
|
||||
- fixed version。
|
||||
- mitigation status。
|
||||
|
||||
## 明确不做
|
||||
|
||||
- 不强制签名。
|
||||
- 不接外部漏洞库作为强依赖。
|
||||
- 不做双人审批。
|
||||
- 不自动升级或自动启用仓库版本。
|
||||
|
||||
## 实现任务
|
||||
|
||||
1. 实现 policy engine。
|
||||
2. 实现 review API/UI。
|
||||
3. 实现 denylist/quarantine/revoke。
|
||||
4. 实现 conflict check。
|
||||
5. 实现 preflight API 和结果存储。
|
||||
6. 实现 self-test profile。
|
||||
7. 实现 benchmark result 存储和门禁。
|
||||
8. 实现 security advisory import/rescan/ack。
|
||||
9. 将门禁接入 enable、rollback、promotion import apply。
|
||||
|
||||
## 验收
|
||||
|
||||
- 未 review 的高风险 protocol-proxy 插件不能在 prod profile 启用。
|
||||
- 两个同 scope protocol-proxy 插件不能同时启用。
|
||||
- required feature 缺失返回 `feature_missing`。
|
||||
- secret 缺失阻断启用。
|
||||
- advisory revoke 后不能 rollback 到受影响 artifact。
|
||||
- warning override 过期后重新阻断相关操作。
|
||||
|
||||
## 回滚策略
|
||||
|
||||
- policy 变更不应立即删除运行中插件;先标记 drift/review_required 或 quarantine。
|
||||
- quarantine 从 dispatch table 移除插件,新连接不进入;已有 protocol-proxy 连接按策略 drain/force close。
|
||||
- 管理员可以回滚到未受阻断的旧 artifact。
|
||||
@@ -0,0 +1,123 @@
|
||||
# 阶段 6:Observability And Operations
|
||||
|
||||
## 目标
|
||||
|
||||
补齐生产运行所需的观测和运维能力:metrics、业务事件、trace、日志、诊断包、后台任务、plugin_data、PluginFileStore、外部依赖治理、GC 和 Runbook。
|
||||
|
||||
阶段结束后,插件出问题时管理员能定位、降级、清理和恢复,而不是只能查看 gateway 日志。
|
||||
|
||||
## 可用性检查点
|
||||
|
||||
阶段结束时必须能做到:
|
||||
|
||||
- 管理员能看到 handler calls、duration、panic、timeout、active proxy connections。
|
||||
- 插件可以上报脱敏业务事件和自定义指标。
|
||||
- trace 能关联 connection、plugin handler、external dependency、backend dial。
|
||||
- 插件能注册 interval/manual background task。
|
||||
- 插件能使用 PluginDataStore 和 PluginFileStore,并受配额限制。
|
||||
- 管理员能执行 plugin_data/file/log/artifact GC dry-run 和清理。
|
||||
|
||||
## 范围
|
||||
|
||||
### Metrics
|
||||
|
||||
实现:
|
||||
|
||||
- plugin handler calls。
|
||||
- duration histogram。
|
||||
- errors/panic/timeout。
|
||||
- active calls。
|
||||
- active proxy connections。
|
||||
- build duration/failures。
|
||||
- external dependency requests/duration/inflight/circuit state。
|
||||
- event delivery queue/drop/dead letter。
|
||||
|
||||
### Events 和 custom metrics
|
||||
|
||||
- 插件 manifest 声明 event schema。
|
||||
- `EmitEvent` 接收低基数字段。
|
||||
- 未声明或高基数字段拒绝或 drop。
|
||||
- 最近事件摘要保留。
|
||||
- custom metric schema 和低基数 label 限制。
|
||||
|
||||
### Tracing
|
||||
|
||||
- gateway 生成 connection ID 和 trace ID。
|
||||
- SDK 通过 context 传递。
|
||||
- 日志带 trace/connection ID。
|
||||
- trace 摘要脱敏。
|
||||
- 默认不向第三方依赖注入 `traceparent`,除非策略允许。
|
||||
|
||||
### Background Task
|
||||
|
||||
- interval/manual。
|
||||
- run-on-start。
|
||||
- jitter。
|
||||
- timeout。
|
||||
- non-reentrant。
|
||||
- manual trigger 权限和 confirm token。
|
||||
- last/next run、skipped、consecutive failures。
|
||||
|
||||
### Data 和 files
|
||||
|
||||
PluginDataStore:
|
||||
|
||||
- schema version。
|
||||
- data class。
|
||||
- quota。
|
||||
- retention。
|
||||
- exportable 标记。
|
||||
- GC。
|
||||
|
||||
PluginFileStore:
|
||||
|
||||
- resources readonly。
|
||||
- runtime data/cache/tmp/log/diagnostic。
|
||||
- path traversal 防护。
|
||||
- quota。
|
||||
- retention。
|
||||
- orphaned dir 检测。
|
||||
|
||||
### External dependencies
|
||||
|
||||
- endpoint、purpose、required、timeout、retry、fail policy。
|
||||
- `ExternalClient` 受控 HTTP/TCP 调用。
|
||||
- health check。
|
||||
- circuit breaker。
|
||||
- data classes。
|
||||
- 最近错误摘要。
|
||||
|
||||
## 明确不做
|
||||
|
||||
- 不承诺 native plugin 无法绕过 ExternalClient。
|
||||
- 不默认开启 Prometheus/OTel exporter 的完整外部集成。
|
||||
- 不保存完整 packet payload、secret、token、session response。
|
||||
|
||||
## 实现任务
|
||||
|
||||
1. 实现 metrics 内部模型和 Admin API。
|
||||
2. 实现 business event/custom metric SDK。
|
||||
3. 实现 trace summary。
|
||||
4. 实现 plugin logger 和日志摘要。
|
||||
5. 实现 background task 注册和状态。
|
||||
6. 实现 PluginDataStore。
|
||||
7. 实现 PluginFileStore。
|
||||
8. 实现 ExternalClient。
|
||||
9. 实现 diagnostic package。
|
||||
10. 实现 GC APIs 和 Runbook。
|
||||
|
||||
## 验收
|
||||
|
||||
- mc-auth-proxy 示例能上报 `auth.success`/`auth.failure` 摘要。
|
||||
- session server 调用通过 ExternalClient 记录 latency 和错误。
|
||||
- background task 超时不会阻塞连接路径。
|
||||
- plugin_data 超配额时写入失败且不会无限增长 SQLite。
|
||||
- 诊断包不包含 secret 明文和完整 packet。
|
||||
- GC dry-run 能展示将清理的对象和大小。
|
||||
|
||||
## 回滚策略
|
||||
|
||||
- exporter 失败不能影响连接路径。
|
||||
- event 队列满默认 drop,不阻塞主流程。
|
||||
- background task 可取消;disable 插件时任务停止。
|
||||
- plugin_data/file GC 先 dry-run,清理操作写审计。
|
||||
@@ -0,0 +1,116 @@
|
||||
# 阶段 7:Extension Ecosystem
|
||||
|
||||
## 目标
|
||||
|
||||
在稳定的插件主路径上扩展生态能力:route resolver、status ping、event subscriber、provider、middleware、rule/policy engine、Admin auth provider 和更多官方示例插件。
|
||||
|
||||
阶段结束后,用户可以不写完整 protocol-proxy,也能用更低成本 extension point 完成常见运维需求。
|
||||
|
||||
## 可用性检查点
|
||||
|
||||
阶段结束时必须能做到:
|
||||
|
||||
- 官方 rule/policy 插件能完成 host rewrite、IP 黑白名单、简单限流或维护模式。
|
||||
- route provider 能从外部源或缓存产生可解释 route decision。
|
||||
- status ping 插件能自定义 MOTD/版本提示。
|
||||
- event subscriber 能异步投递审计或插件事件。
|
||||
- Admin auth provider 如果启用,不影响 MC 连接路径,本地 admin break-glass 保留。
|
||||
|
||||
## 范围
|
||||
|
||||
### Route
|
||||
|
||||
- `route.resolve/v1`
|
||||
- `route.resolver/v1`
|
||||
- route decision schema。
|
||||
- provider cache。
|
||||
- SQLite fallback。
|
||||
- refresh action。
|
||||
|
||||
### Status
|
||||
|
||||
- `status.ping/v1`
|
||||
- MOTD。
|
||||
- favicon。
|
||||
- online/max players。
|
||||
- version text。
|
||||
- maintenance window。
|
||||
|
||||
### Middleware
|
||||
|
||||
预留并选择性实现:
|
||||
|
||||
- `connection.filter/v1`
|
||||
- `handshake.filter/v1`
|
||||
|
||||
必须有确定顺序、timeout、panic recover 和 fail policy。
|
||||
|
||||
### Provider
|
||||
|
||||
- provider singleton。
|
||||
- priority/fallback。
|
||||
- plugin dependencies。
|
||||
- `auth.provider/v1` 只作为插件间认证来源复用,不给 gateway core 组装 MC 登录流程。
|
||||
|
||||
### Event subscriber
|
||||
|
||||
- best_effort。
|
||||
- at_least_once。
|
||||
- queue。
|
||||
- retry。
|
||||
- dead letter。
|
||||
- replay/drop action。
|
||||
|
||||
### Rule / Policy Engine
|
||||
|
||||
官方插件形式提供:
|
||||
|
||||
- host rewrite。
|
||||
- source CIDR allow/deny。
|
||||
- simple rate limit。
|
||||
- maintenance mode。
|
||||
- upstream rewrite。
|
||||
|
||||
### Admin Auth Provider
|
||||
|
||||
预留或实现:
|
||||
|
||||
- OIDC。
|
||||
- LDAP。
|
||||
- external identity binding。
|
||||
- break-glass local admin。
|
||||
- gateway-issued session。
|
||||
|
||||
## 明确不做
|
||||
|
||||
- 不开放 play 阶段 packet filter 作为默认生产能力。
|
||||
- 不让 `auth.provider/v1` 进入 gateway core MC 登录流水线。
|
||||
- 不允许插件自定义 Admin 权限绕过 gateway 权限模型。
|
||||
- 不允许插件注入 Admin 自定义 HTML/JS。
|
||||
|
||||
## 实现任务
|
||||
|
||||
1. 实现 route decision 模型。
|
||||
2. 实现 route provider cache 和 refresh action。
|
||||
3. 实现 status ping extension point。
|
||||
4. 实现 event subscriber delivery。
|
||||
5. 实现 provider registry。
|
||||
6. 实现 rule/policy 官方插件。
|
||||
7. 预留或实现 Admin auth provider。
|
||||
8. 增加示例插件和 conformance fixture。
|
||||
|
||||
## 验收
|
||||
|
||||
- route provider 返回 override/fallback/reject/pass 都能在 Admin 解释。
|
||||
- 外部 route source 不可用时能使用 cache 或 SQLite fallback。
|
||||
- status 插件能按 host 返回不同 MOTD。
|
||||
- event subscriber 失败不影响连接路径。
|
||||
- rule 插件配置错误不会破坏默认路由。
|
||||
- Admin auth provider 不可用时,本地 admin 仍可登录。
|
||||
|
||||
## 回滚策略
|
||||
|
||||
- route provider disable 后恢复 SQLite route snapshot。
|
||||
- status 插件 disable 后恢复默认 status。
|
||||
- event subscriber disable 后只停止外部投递,不删除本地审计。
|
||||
- rule 插件冲突时通过 priority/scope 修复或禁用。
|
||||
@@ -0,0 +1,180 @@
|
||||
# 阶段 8:Future Runtimes And Distribution
|
||||
|
||||
## 目标
|
||||
|
||||
引入最终设计中的未来能力,同时保持前七个阶段的默认路径可用。包括 `go-plugin-process`、sandbox-process、WASM、ingress service、插件仓库、签名、SBOM 漏洞扫描、许可证策略和 build-time instrumentation。
|
||||
|
||||
本阶段由多个可选子阶段组成。每个子阶段都必须可以单独启用或回滚,不能要求一次性切换所有 runtime。
|
||||
|
||||
## 可用性检查点
|
||||
|
||||
阶段结束时必须能做到:
|
||||
|
||||
- 默认 `in-process go-plugin` 路径仍然可用。
|
||||
- 管理后台可以配置插件服务启动模式 desired value,并明确 restart required。
|
||||
- `go-plugin-process` 至少支持 drain-only。
|
||||
- sandbox-process/WASM 如果启用,capabilities 能被强制或阻断启用。
|
||||
- 仓库导入只生成本地 artifact,不自动启用生产流量。
|
||||
- 签名、SBOM、license 和 advisory 策略能参与准入结果。
|
||||
|
||||
## 子阶段 A:go-plugin-process
|
||||
|
||||
实现:
|
||||
|
||||
- gateway 插件服务启动模式:`in-process`、`go-plugin-process`、`sandbox-process`。
|
||||
- Admin 系统配置项 `plugin_service.desired_mode`。
|
||||
- 启动时读取 desired mode,校验后写入 `plugin_service.active_mode` 和 `applied_at`。
|
||||
- `restart_required` 由 desired/active 差异推导。
|
||||
- 环境级连接迁移开关:`drain-only`、`fd-live`、`fd-live-shm`。
|
||||
- plugin-host supervisor。
|
||||
- 子进程 lifecycle。
|
||||
- UDS control channel。
|
||||
- `drain-only` 进程级卸载。
|
||||
- 可选 `fd-live`。
|
||||
- 可选 `fd-live-shm`。
|
||||
- shared memory state schema。
|
||||
- `quiesce/snapshot/restore` conformance。
|
||||
|
||||
默认:
|
||||
|
||||
- `in-process`。
|
||||
- `active_mode` 只由 gateway 启动流程写入。
|
||||
- `go-plugin-process` 切换需要重启。
|
||||
- live migration 默认关闭。
|
||||
- 迁移默认模式为 `drain-only`。
|
||||
|
||||
验收:
|
||||
|
||||
- Admin 修改 `plugin_service.desired_mode` 后不立即切换当前进程,页面展示 restart required。
|
||||
- 重启后 gateway 按 desired mode 创建对应 RuntimeAdapter 或 plugin-host supervisor,并更新 active mode。
|
||||
- `go-plugin-process` 模式下启用 upstream-rewrite。
|
||||
- disable 后旧 plugin-host drain 并退出。
|
||||
- 子进程退出后 `.so` 和 Go heap 被 OS 回收。
|
||||
- crash loop 不影响 Admin 主进程。
|
||||
|
||||
## 子阶段 B:sandbox-process
|
||||
|
||||
实现:
|
||||
|
||||
- control RPC。
|
||||
- supervisor。
|
||||
- crash loop policy。
|
||||
- secret RPC/handle。
|
||||
- filesystem/network/env/cpu/memory capability enforcement。
|
||||
- stream relay 或 `stream.proxy/v1`。
|
||||
|
||||
验收:
|
||||
|
||||
- sandbox 插件崩溃不导致 gateway 崩溃。
|
||||
- 无法强制 required capability 时阻断启用。
|
||||
- secret 不通过长期环境变量注入。
|
||||
|
||||
## 子阶段 C:WASM
|
||||
|
||||
实现:
|
||||
|
||||
- WASM host ABI。
|
||||
- route/rule/config validate extension point。
|
||||
- memory/time limits。
|
||||
- no file/no network 默认策略。
|
||||
|
||||
验收:
|
||||
|
||||
- WASM rule 插件可以返回 allow/deny/rewrite。
|
||||
- 超时或内存超限只影响当前调用。
|
||||
- WASM 插件不能访问未授权 secret/network。
|
||||
|
||||
## 子阶段 D:ingress service
|
||||
|
||||
实现:
|
||||
|
||||
- `ingress.service/v1` schema。
|
||||
- service supervisor。
|
||||
- listener ownership by gateway。
|
||||
- port conflict check。
|
||||
- TLS/secret refs。
|
||||
- health and drain。
|
||||
|
||||
验收:
|
||||
|
||||
- 插件声明入口服务后,由 gateway 创建 listener。
|
||||
- disable 后停止接收新连接并 drain。
|
||||
- 端口冲突阻断启用。
|
||||
|
||||
## 子阶段 E:repository and supply chain
|
||||
|
||||
实现:
|
||||
|
||||
- official/internal/file/url repository index。
|
||||
- repository trust policy。
|
||||
- artifact download to local store。
|
||||
- signature verification。
|
||||
- SBOM vulnerability scan。
|
||||
- license allowlist/denylist。
|
||||
- advisory feed sync。
|
||||
- update availability。
|
||||
|
||||
验收:
|
||||
|
||||
- 仓库候选版本导入后仍需本地 review。
|
||||
- 仓库删除版本不删除本地 artifact。
|
||||
- denylist/advisory 仍阻断 rollback 和 promotion apply。
|
||||
|
||||
## 子阶段 F:build-time instrumentation
|
||||
|
||||
实现:
|
||||
|
||||
- instrumentation manifest。
|
||||
- official/organization CI profile。
|
||||
- generated diff hash。
|
||||
- provenance。
|
||||
- conformance/benchmark/smoke gate。
|
||||
|
||||
验收:
|
||||
|
||||
- 插桩产物作为 gateway binary 发布,不进入 plugin artifact hot-load lifecycle。
|
||||
- Admin 展示 instrumentation metadata。
|
||||
- 插桩影响连接路径时 Runbook 说明回滚方式。
|
||||
|
||||
## 明确不做
|
||||
|
||||
- 不把 `go-plugin-process` 当成不可信 sandbox。
|
||||
- 不把 fd 迁移当成跨平台通用能力。
|
||||
- 不把 WASM 用于完整 MC protocol-proxy。
|
||||
- 不让仓库自动启用生产插件。
|
||||
- 不允许普通上传包携带插桩规则直接改 gateway binary。
|
||||
|
||||
## 实现任务
|
||||
|
||||
本阶段按子阶段逐个实施。每个子阶段都必须满足:
|
||||
|
||||
1. 默认 `in-process go-plugin` 路径不回归。
|
||||
2. 新 runtime 或供应链能力可通过 feature flag、service mode 或 policy profile 关闭。
|
||||
3. Admin/API 能展示当前 active 状态、desired 状态和失败原因。
|
||||
4. promotion import 不能自动启用目标环境不支持的 runtime 或策略。
|
||||
5. conformance 覆盖新增 manifest 字段、feature key、错误码和回滚路径。
|
||||
|
||||
建议顺序:
|
||||
|
||||
1. 先实现 service mode 数据模型和 Admin 展示。
|
||||
2. 再实现 `go-plugin-process` 的 `drain-only`。
|
||||
3. 再评估 `fd-live` 和 `fd-live-shm`。
|
||||
4. 然后引入 sandbox-process 和 WASM。
|
||||
5. 最后引入仓库、签名、SBOM 扫描和 build-time instrumentation。
|
||||
|
||||
## 验收
|
||||
|
||||
- 未开启任何 future runtime 时,阶段 1 到阶段 7 的插件能力仍通过回归验证。
|
||||
- service mode 从 `in-process` 切换到 `go-plugin-process` 时明确提示 restart required。
|
||||
- `go-plugin-process` 子进程 crash 不导致 Admin 主进程退出,并能展示 crash loop 状态。
|
||||
- sandbox-process required capability 无法强制时,启用被阻断而不是降级为只审计。
|
||||
- WASM 插件超时、panic 或内存超限只影响当前调用。
|
||||
- repository import 只生成本地 artifact,并重新进入本地准入、review、enable 流程。
|
||||
- build-time instrumentation 产物不会出现在 runtime plugin enable/disable 列表中。
|
||||
|
||||
## 回滚策略
|
||||
|
||||
- runtime mode 切换失败时回到 `in-process`。
|
||||
- sandbox/WASM 插件失败时禁用对应 plugin,不影响 `go-plugin` 插件。
|
||||
- repository 功能失败不影响本地 artifact。
|
||||
- build-time instrumentation 回滚到上一 gateway binary。
|
||||
7894
docs/plugin-system-design.md
Normal file
7894
docs/plugin-system-design.md
Normal file
File diff suppressed because it is too large
Load Diff
35
examples/plugins/mc-auth-proxy/README.md
Normal file
35
examples/plugins/mc-auth-proxy/README.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# MC Auth Proxy Plugin
|
||||
|
||||
This example registers `upstream.connect/v1` in protocol-proxy mode. It receives
|
||||
the complete Minecraft byte stream from the gateway, reads the handshake and
|
||||
login start packets, then returns a login disconnect response unless
|
||||
`fixture_accept` is enabled.
|
||||
|
||||
The example is intentionally small: gateway core does not parse authentication
|
||||
results, identity mapping, forwarding, or play packets. Those responsibilities
|
||||
belong inside a protocol-proxy plugin.
|
||||
|
||||
Build and package:
|
||||
|
||||
```sh
|
||||
./build.sh
|
||||
```
|
||||
|
||||
The binary package is written to `dist/mc-auth-proxy.mcgp`; the source package
|
||||
is written to `dist/mc-auth-proxy-source.mcgp`.
|
||||
|
||||
Build the source package through the gateway builder:
|
||||
|
||||
```sh
|
||||
go run ../../../cmd/gateway plugin source-build dist/mc-auth-proxy-source.mcgp dist/mc-auth-proxy-built.mcgp
|
||||
```
|
||||
|
||||
Example config JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"match_host": "play.example",
|
||||
"fixture_accept": false,
|
||||
"disconnect_message": "Authentication fixture rejected the login"
|
||||
}
|
||||
```
|
||||
23
examples/plugins/mc-auth-proxy/build.sh
Executable file
23
examples/plugins/mc-auth-proxy/build.sh
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
mkdir -p dist
|
||||
go build -buildmode=plugin -o dist/plugin.so .
|
||||
go run ./cmd/render-manifest > dist/manifest.json
|
||||
cp README.md dist/README.md
|
||||
(
|
||||
cd dist
|
||||
rm -f mc-auth-proxy.mcgp
|
||||
zip -q mc-auth-proxy.mcgp manifest.json plugin.so README.md
|
||||
)
|
||||
rm -rf dist/source-package
|
||||
mkdir -p dist/source-package/cmd/render-manifest
|
||||
ARTIFACT_TYPE=source go run ./cmd/render-manifest > dist/source-package/manifest.json
|
||||
cp main.go main_test.go go.mod README.md dist/source-package/
|
||||
cp cmd/render-manifest/main.go dist/source-package/cmd/render-manifest/main.go
|
||||
go mod vendor -o dist/source-package/vendor
|
||||
(
|
||||
cd dist/source-package
|
||||
rm -f ../mc-auth-proxy-source.mcgp
|
||||
zip -qr ../mc-auth-proxy-source.mcgp manifest.json main.go main_test.go go.mod README.md cmd/render-manifest/main.go vendor
|
||||
)
|
||||
119
examples/plugins/mc-auth-proxy/cmd/render-manifest/main.go
Normal file
119
examples/plugins/mc-auth-proxy/cmd/render-manifest/main.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func main() {
|
||||
artifactType := os.Getenv("ARTIFACT_TYPE")
|
||||
if artifactType == "" {
|
||||
artifactType = "binary"
|
||||
}
|
||||
manifest := map[string]any{
|
||||
"schema_version": "mc-gateway.plugin/v1",
|
||||
"id": "mc-auth-proxy",
|
||||
"name": "Minecraft Auth Proxy",
|
||||
"version": "0.1.0",
|
||||
"description": "Protocol-proxy example that reads handshake/login start and returns a login disconnect fixture.",
|
||||
"artifact_type": artifactType,
|
||||
"runtime": map[string]any{
|
||||
"type": "go-plugin",
|
||||
"entry": "plugin.so",
|
||||
"entry_symbol": "Plugin",
|
||||
"metadata_symbol": "MCGatewayPluginMetadata",
|
||||
},
|
||||
"api_version": "plugin-api/v1",
|
||||
"sdk_module": "github.com/tursom/mc-gateway/plugin/api",
|
||||
"sdk_module_version": "v0.1.0",
|
||||
"go_version": runtime.Version(),
|
||||
"go_os": runtime.GOOS,
|
||||
"go_arch": runtime.GOARCH,
|
||||
"extension_points": []map[string]any{
|
||||
{"type": "hook", "key": "upstream.connect/v1"},
|
||||
},
|
||||
"capabilities": map[string]any{
|
||||
"extension_points": []string{"upstream.connect/v1"},
|
||||
"upstream_connect": map[string]any{"mode": "protocol-proxy"},
|
||||
"minecraft": map[string]any{
|
||||
"protocol_versions": map[string]any{
|
||||
"min": 760,
|
||||
"max": 767,
|
||||
"tested": []int{760, 763, 765, 767},
|
||||
"unsupported_policy": "kick",
|
||||
},
|
||||
"states": map[string]any{
|
||||
"status": "transparent",
|
||||
"login": "handled",
|
||||
"configuration": "transparent",
|
||||
"play": "transparent",
|
||||
},
|
||||
"auth_modes": []string{"fixture"},
|
||||
"forwarding": map[string]any{
|
||||
"supported": []string{"none", "velocity-modern"},
|
||||
"default": "none",
|
||||
"requires_secret": false,
|
||||
},
|
||||
"unsupported_policy": "kick",
|
||||
"modded": map[string]any{
|
||||
"forge": "transparent",
|
||||
"fabric": "transparent",
|
||||
"unknown": "pass",
|
||||
},
|
||||
},
|
||||
"network": map[string]any{"outbound": []string{"tcp:*:*"}},
|
||||
"filesystem": map[string]any{"read": []string{}, "write": []string{}},
|
||||
"env": []string{},
|
||||
},
|
||||
"runtime_limits": map[string]any{
|
||||
"handler_timeout_ms": 3000,
|
||||
"initial_write_timeout_ms": 1000,
|
||||
},
|
||||
"events": []map[string]any{
|
||||
{"name": "auth.success", "fields": []string{"result", "mode"}},
|
||||
{"name": "auth.failure", "fields": []string{"result", "mode"}},
|
||||
},
|
||||
"custom_metrics": []map[string]any{
|
||||
{"name": "auth.attempts", "type": "counter", "labels": []string{"result", "mode"}},
|
||||
},
|
||||
"external_dependencies": []map[string]any{
|
||||
{"name": "backend", "endpoint": "tcp://", "purpose": "auth", "required": true, "timeout": "3s", "retry": 0, "fail_policy": "fail_closed", "data_classes": []string{"operational"}},
|
||||
},
|
||||
"background_tasks": []map[string]any{
|
||||
{"id": "profile-cache-gc", "name": "Profile cache GC", "mode": "manual", "manual": true, "timeout": "1s"},
|
||||
},
|
||||
"data_stores": []map[string]any{
|
||||
{"name": "profile-cache", "schema_version": 1, "data_class": "profile_cache", "quota_bytes": 1048576, "retention": "24h", "exportable": false},
|
||||
},
|
||||
"file_stores": []map[string]any{
|
||||
{"namespace": "cache", "data_class": "profile_cache", "quota_bytes": 1048576, "retention": "24h"},
|
||||
{"namespace": "diagnostic", "data_class": "diagnostic", "quota_bytes": 1048576, "retention": "24h"},
|
||||
},
|
||||
"config_schema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"match_host": map[string]any{"type": "string"},
|
||||
"fixture_accept": map[string]any{"type": "boolean"},
|
||||
"disconnect_message": map[string]any{"type": "string"},
|
||||
"backend": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if artifactType == "source" {
|
||||
manifest["build"] = map[string]any{
|
||||
"type": "go",
|
||||
"entry": ".",
|
||||
"go_version": runtime.Version(),
|
||||
"cgo_enabled": true,
|
||||
"tags": []string{},
|
||||
"vendor_required": false,
|
||||
"output": "plugin.so",
|
||||
}
|
||||
}
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(manifest); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
7
examples/plugins/mc-auth-proxy/go.mod
Normal file
7
examples/plugins/mc-auth-proxy/go.mod
Normal file
@@ -0,0 +1,7 @@
|
||||
module github.com/tursom/mc-gateway/examples/plugins/mc-auth-proxy
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require github.com/tursom/mc-gateway v0.0.0
|
||||
|
||||
replace github.com/tursom/mc-gateway => ../../..
|
||||
290
examples/plugins/mc-auth-proxy/main.go
Normal file
290
examples/plugins/mc-auth-proxy/main.go
Normal file
@@ -0,0 +1,290 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/tursom/mc-gateway/plugin/api"
|
||||
"github.com/tursom/mc-gateway/protocol"
|
||||
)
|
||||
|
||||
type PluginImpl struct {
|
||||
api.AbstractPlugin
|
||||
config Config
|
||||
gateway api.Gateway
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
MatchHost string `json:"match_host"`
|
||||
FixtureAccept bool `json:"fixture_accept"`
|
||||
DisconnectMessage string `json:"disconnect_message"`
|
||||
Backend string `json:"backend"`
|
||||
}
|
||||
|
||||
type loginStart struct {
|
||||
Username string
|
||||
}
|
||||
|
||||
func Plugin() api.Plugin {
|
||||
return &PluginImpl{}
|
||||
}
|
||||
|
||||
func MCGatewayPluginMetadata() string {
|
||||
return manifestJSON
|
||||
}
|
||||
|
||||
func (p *PluginImpl) NewConfigObj() any {
|
||||
return &Config{}
|
||||
}
|
||||
|
||||
func (p *PluginImpl) ReloadConfig(config any) error {
|
||||
if cfg, ok := config.(*Config); ok {
|
||||
p.config = *cfg
|
||||
}
|
||||
if p.config.DisconnectMessage == "" {
|
||||
p.config.DisconnectMessage = "Authentication fixture rejected the login"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PluginImpl) Init(gateway api.Gateway) error {
|
||||
p.gateway = gateway
|
||||
return api.RegisterHookHandler(
|
||||
gateway,
|
||||
api.HookUpstreamConnect,
|
||||
func(req api.UpstreamConnectRequest) bool {
|
||||
return p.config.MatchHost == "" || req.Host == p.config.MatchHost
|
||||
},
|
||||
func(req api.UpstreamConnectRequest) (net.Conn, error) {
|
||||
if p.config.MatchHost != "" && req.Host != p.config.MatchHost {
|
||||
return nil, api.ErrPass
|
||||
}
|
||||
gatewayEnd, pluginEnd := net.Pipe()
|
||||
go p.handleConn(req, pluginEnd)
|
||||
return gatewayEnd, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (p *PluginImpl) handleConn(req api.UpstreamConnectRequest, conn net.Conn) {
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
|
||||
handshakePacket, err := readPacketFromConn(conn)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
handshake := protocol.ParseHandshake(handshakePacket)
|
||||
if handshake.ServerHost == "" || handshake.NextState != 2 {
|
||||
p.emitAuthEvent(req.Context, "auth.failure", "bad_handshake")
|
||||
_ = writeLoginDisconnect(conn, "Unsupported Minecraft handshake")
|
||||
return
|
||||
}
|
||||
|
||||
loginPacket, err := readPacketFromConn(conn)
|
||||
if err != nil {
|
||||
_ = writeLoginDisconnect(conn, p.config.DisconnectMessage)
|
||||
return
|
||||
}
|
||||
login, err := parseLoginStart(loginPacket)
|
||||
if err != nil || login.Username == "" {
|
||||
p.emitAuthEvent(req.Context, "auth.failure", "bad_login_start")
|
||||
_ = writeLoginDisconnect(conn, p.config.DisconnectMessage)
|
||||
return
|
||||
}
|
||||
|
||||
if !p.config.FixtureAccept {
|
||||
p.emitAuthEvent(req.Context, "auth.failure", "fixture_reject")
|
||||
_ = writeLoginDisconnect(conn, p.config.DisconnectMessage)
|
||||
return
|
||||
}
|
||||
if p.config.Backend == "" {
|
||||
p.emitAuthEvent(req.Context, "auth.failure", "backend_missing")
|
||||
_ = writeLoginDisconnect(conn, "Fixture accepted but no backend is configured")
|
||||
return
|
||||
}
|
||||
|
||||
backend, err := p.gateway.ExternalClient("backend").DialTCP(req.Context, p.config.Backend, 3*time.Second)
|
||||
if err != nil {
|
||||
p.emitAuthEvent(req.Context, "auth.failure", "backend_unavailable")
|
||||
_ = writeLoginDisconnect(conn, "Backend unavailable")
|
||||
return
|
||||
}
|
||||
p.emitAuthEvent(req.Context, "auth.success", "fixture_accept")
|
||||
defer backend.Close()
|
||||
_, _ = backend.Write(handshakePacket)
|
||||
_, _ = backend.Write(loginPacket)
|
||||
copyBoth(conn, backend)
|
||||
_ = req
|
||||
}
|
||||
|
||||
func (p *PluginImpl) emitAuthEvent(ctx context.Context, name, result string) {
|
||||
if p.gateway == nil {
|
||||
return
|
||||
}
|
||||
_ = p.gateway.EmitEvent(ctx, name, map[string]string{
|
||||
"result": result,
|
||||
"mode": "fixture",
|
||||
})
|
||||
p.gateway.Logger().Info(ctx, name, map[string]string{"result": result})
|
||||
}
|
||||
|
||||
func readPacketFromConn(conn net.Conn) ([]byte, error) {
|
||||
length, err := readVarIntFromConn(conn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if length <= 0 || length > 2*1024*1024 {
|
||||
return nil, fmt.Errorf("invalid packet length %d", length)
|
||||
}
|
||||
packet := make([]byte, length)
|
||||
if _, err := io.ReadFull(conn, packet); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := append(encodeVarInt(length), packet...)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseLoginStart(packet []byte) (loginStart, error) {
|
||||
payload, _, err := protocol.ReadPacket(packet)
|
||||
if err != nil {
|
||||
return loginStart{}, err
|
||||
}
|
||||
packetID, n, err := protocol.ReadVarInt(payload)
|
||||
if err != nil {
|
||||
return loginStart{}, err
|
||||
}
|
||||
if packetID != 0 {
|
||||
return loginStart{}, errors.New("not a login start packet")
|
||||
}
|
||||
username, _, err := protocol.ReadString(payload[n:])
|
||||
if err != nil {
|
||||
return loginStart{}, err
|
||||
}
|
||||
return loginStart{Username: username}, nil
|
||||
}
|
||||
|
||||
func writeLoginDisconnect(conn net.Conn, message string) error {
|
||||
payload := []byte{0x00}
|
||||
text, _ := json.Marshal(map[string]any{"text": message})
|
||||
payload = append(payload, encodeVarInt(len(text))...)
|
||||
payload = append(payload, text...)
|
||||
packet := append(encodeVarInt(len(payload)), payload...)
|
||||
_, err := conn.Write(packet)
|
||||
return err
|
||||
}
|
||||
|
||||
func readVarIntFromConn(r io.Reader) (int, error) {
|
||||
var value int
|
||||
var one [1]byte
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := io.ReadFull(r, one[:]); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
b := one[0]
|
||||
value |= int(b&0x7f) << (7 * i)
|
||||
if b&0x80 == 0 {
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
return 0, errors.New("varint too long")
|
||||
}
|
||||
|
||||
func encodeVarInt(value int) []byte {
|
||||
var out []byte
|
||||
for {
|
||||
b := byte(value & 0x7f)
|
||||
value >>= 7
|
||||
if value != 0 {
|
||||
b |= 0x80
|
||||
}
|
||||
out = append(out, b)
|
||||
if value == 0 {
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func copyBoth(a, b net.Conn) {
|
||||
done := make(chan struct{}, 2)
|
||||
go func() {
|
||||
_, _ = io.Copy(a, b)
|
||||
done <- struct{}{}
|
||||
}()
|
||||
go func() {
|
||||
_, _ = io.Copy(b, a)
|
||||
done <- struct{}{}
|
||||
}()
|
||||
<-done
|
||||
}
|
||||
|
||||
var manifestJSON = compactJSON(map[string]any{
|
||||
"schema_version": "mc-gateway.plugin/v1",
|
||||
"id": "mc-auth-proxy",
|
||||
"name": "Minecraft Auth Proxy",
|
||||
"version": "0.1.0",
|
||||
"description": "Protocol-proxy example that reads handshake/login start and returns a login disconnect fixture.",
|
||||
"artifact_type": "binary",
|
||||
"runtime": map[string]any{
|
||||
"type": "go-plugin",
|
||||
"entry": "plugin.so",
|
||||
"entry_symbol": "Plugin",
|
||||
"metadata_symbol": "MCGatewayPluginMetadata",
|
||||
},
|
||||
"api_version": "plugin-api/v1",
|
||||
"sdk_module": "github.com/tursom/mc-gateway/plugin/api",
|
||||
"go_version": runtime.Version(),
|
||||
"go_os": runtime.GOOS,
|
||||
"go_arch": runtime.GOARCH,
|
||||
"extension_points": []map[string]any{
|
||||
{"type": "hook", "key": "upstream.connect/v1"},
|
||||
},
|
||||
"capabilities": map[string]any{
|
||||
"extension_points": []string{"upstream.connect/v1"},
|
||||
"upstream_connect": map[string]any{"mode": "protocol-proxy"},
|
||||
"minecraft": map[string]any{
|
||||
"protocol_versions": map[string]any{"min": 760, "max": 767, "tested": []int{760, 763, 765, 767}, "unsupported_policy": "kick"},
|
||||
"states": map[string]any{"status": "transparent", "login": "handled", "configuration": "transparent", "play": "transparent"},
|
||||
"auth_modes": []string{"fixture"},
|
||||
"forwarding": map[string]any{"supported": []string{"none", "velocity-modern"}, "default": "none", "requires_secret": false},
|
||||
"unsupported_policy": "kick",
|
||||
"modded": map[string]any{"forge": "transparent", "fabric": "transparent", "unknown": "pass"},
|
||||
},
|
||||
"network": map[string]any{"outbound": []string{"tcp:*:*"}},
|
||||
},
|
||||
"runtime_limits": map[string]any{
|
||||
"handler_timeout_ms": 3000,
|
||||
"initial_write_timeout_ms": 1000,
|
||||
},
|
||||
"events": []map[string]any{
|
||||
{"name": "auth.success", "fields": []string{"result", "mode"}},
|
||||
{"name": "auth.failure", "fields": []string{"result", "mode"}},
|
||||
},
|
||||
"custom_metrics": []map[string]any{
|
||||
{"name": "auth.attempts", "type": "counter", "labels": []string{"result", "mode"}},
|
||||
},
|
||||
"external_dependencies": []map[string]any{
|
||||
{"name": "backend", "endpoint": "tcp://", "purpose": "auth", "required": true, "timeout": "3s", "retry": 0, "fail_policy": "fail_closed", "data_classes": []string{"operational"}},
|
||||
},
|
||||
"background_tasks": []map[string]any{
|
||||
{"id": "profile-cache-gc", "name": "Profile cache GC", "mode": "manual", "manual": true, "timeout": "1s"},
|
||||
},
|
||||
"data_stores": []map[string]any{
|
||||
{"name": "profile-cache", "schema_version": 1, "data_class": "profile_cache", "quota_bytes": 1048576, "retention": "24h", "exportable": false},
|
||||
},
|
||||
"file_stores": []map[string]any{
|
||||
{"namespace": "cache", "data_class": "profile_cache", "quota_bytes": 1048576, "retention": "24h"},
|
||||
{"namespace": "diagnostic", "data_class": "diagnostic", "quota_bytes": 1048576, "retention": "24h"},
|
||||
},
|
||||
})
|
||||
|
||||
func compactJSON(value any) string {
|
||||
data, _ := json.Marshal(value)
|
||||
return string(data)
|
||||
}
|
||||
134
examples/plugins/mc-auth-proxy/main_test.go
Normal file
134
examples/plugins/mc-auth-proxy/main_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tursom/mc-gateway/plugin/api"
|
||||
)
|
||||
|
||||
func TestFixtureRejectsLoginStart(t *testing.T) {
|
||||
plugin := &PluginImpl{}
|
||||
if err := plugin.ReloadConfig(&Config{DisconnectMessage: "fixture rejected"}); err != nil {
|
||||
t.Fatalf("ReloadConfig() error = %v", err)
|
||||
}
|
||||
gateway := &recordingGateway{}
|
||||
plugin.gateway = gateway
|
||||
client, server := net.Pipe()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
plugin.handleConn(upstreamRequestForTest(), server)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
if _, err := client.Write(mcAuthProxyHandshakePacket("play.example")); err != nil {
|
||||
t.Fatalf("write handshake error = %v", err)
|
||||
}
|
||||
if _, err := client.Write(mcAuthProxyLoginStartPacket("Steve")); err != nil {
|
||||
t.Fatalf("write login start error = %v", err)
|
||||
}
|
||||
response, err := readPacketFromConn(client)
|
||||
if err != nil {
|
||||
t.Fatalf("read response error = %v", err)
|
||||
}
|
||||
if !bytes.Contains(response, []byte("fixture rejected")) {
|
||||
t.Fatalf("response = %q, want fixture message", response)
|
||||
}
|
||||
if len(gateway.events) != 1 || gateway.events[0].name != "auth.failure" {
|
||||
t.Fatalf("events = %+v, want auth.failure", gateway.events)
|
||||
}
|
||||
_ = client.Close()
|
||||
<-done
|
||||
}
|
||||
|
||||
func TestParseLoginStart(t *testing.T) {
|
||||
login, err := parseLoginStart(mcAuthProxyLoginStartPacket("Alex"))
|
||||
if err != nil {
|
||||
t.Fatalf("parseLoginStart() error = %v", err)
|
||||
}
|
||||
if login.Username != "Alex" {
|
||||
t.Fatalf("username = %q, want Alex", login.Username)
|
||||
}
|
||||
}
|
||||
|
||||
func upstreamRequestForTest() api.UpstreamConnectRequest {
|
||||
return api.UpstreamConnectRequest{}
|
||||
}
|
||||
|
||||
type recordedEvent struct {
|
||||
name string
|
||||
fields map[string]string
|
||||
}
|
||||
|
||||
type recordingGateway struct {
|
||||
events []recordedEvent
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func (g *recordingGateway) HandleConn(net.Conn) {}
|
||||
func (g *recordingGateway) ExitWaitGroup() *sync.WaitGroup { return &g.wg }
|
||||
func (g *recordingGateway) Hook(string, any) error { return nil }
|
||||
func (g *recordingGateway) EmitEvent(_ context.Context, name string, fields map[string]string) error {
|
||||
g.events = append(g.events, recordedEvent{name: name, fields: fields})
|
||||
return nil
|
||||
}
|
||||
func (g *recordingGateway) ObserveMetric(context.Context, string, float64, map[string]string) error {
|
||||
return nil
|
||||
}
|
||||
func (g *recordingGateway) Logger() api.Logger { return testLogger{} }
|
||||
func (g *recordingGateway) DataStore() api.DataStore { return testDataStore{} }
|
||||
func (g *recordingGateway) FileStore() api.FileStore { return testFileStore{} }
|
||||
func (g *recordingGateway) ExternalClient(string) api.ExternalClient { return testExternalClient{} }
|
||||
func (g *recordingGateway) RegisterBackgroundTask(api.BackgroundTask) error { return nil }
|
||||
|
||||
type testLogger struct{}
|
||||
|
||||
func (testLogger) Debug(context.Context, string, map[string]string) {}
|
||||
func (testLogger) Info(context.Context, string, map[string]string) {}
|
||||
func (testLogger) Warn(context.Context, string, map[string]string) {}
|
||||
func (testLogger) Error(context.Context, string, map[string]string) {}
|
||||
|
||||
type testDataStore struct{}
|
||||
|
||||
func (testDataStore) Put(context.Context, api.DataRecord) error { return nil }
|
||||
func (testDataStore) Get(context.Context, string) (api.DataRecord, error) {
|
||||
return api.DataRecord{}, nil
|
||||
}
|
||||
func (testDataStore) Delete(context.Context, string) error { return nil }
|
||||
|
||||
type testFileStore struct{}
|
||||
|
||||
func (testFileStore) ResourcePath(string) (string, error) { return "", nil }
|
||||
func (testFileStore) Write(context.Context, string, string, []byte, string, time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
func (testFileStore) Read(context.Context, string, string, int64) ([]byte, error) { return nil, nil }
|
||||
func (testFileStore) Delete(context.Context, string, string) error { return nil }
|
||||
|
||||
type testExternalClient struct{}
|
||||
|
||||
func (testExternalClient) DoHTTP(context.Context, api.ExternalRequest) (api.ExternalResponse, error) {
|
||||
return api.ExternalResponse{}, nil
|
||||
}
|
||||
func (testExternalClient) DialTCP(context.Context, string, time.Duration) (net.Conn, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (testExternalClient) HealthCheck(context.Context) error { return nil }
|
||||
|
||||
func mcAuthProxyHandshakePacket(host string) []byte {
|
||||
payload := []byte{0x00, 0x63, byte(len(host))}
|
||||
payload = append(payload, host...)
|
||||
payload = append(payload, 0x63, 0xdd, 0x02)
|
||||
return append(encodeVarInt(len(payload)), payload...)
|
||||
}
|
||||
|
||||
func mcAuthProxyLoginStartPacket(username string) []byte {
|
||||
payload := []byte{0x00}
|
||||
payload = append(payload, encodeVarInt(len(username))...)
|
||||
payload = append(payload, username...)
|
||||
return append(encodeVarInt(len(payload)), payload...)
|
||||
}
|
||||
94
examples/plugins/mc-auth-proxy/manifest.json
Normal file
94
examples/plugins/mc-auth-proxy/manifest.json
Normal file
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"schema_version": "mc-gateway.plugin/v1",
|
||||
"id": "mc-auth-proxy",
|
||||
"name": "Minecraft Auth Proxy",
|
||||
"version": "0.1.0",
|
||||
"description": "Protocol-proxy example that owns Minecraft login handling and returns a stable fixture disconnect.",
|
||||
"artifact_type": "binary",
|
||||
"runtime": {
|
||||
"type": "go-plugin",
|
||||
"entry": "plugin.so",
|
||||
"entry_symbol": "Plugin",
|
||||
"metadata_symbol": "MCGatewayPluginMetadata"
|
||||
},
|
||||
"api_version": "plugin-api/v1",
|
||||
"sdk_module": "github.com/tursom/mc-gateway/plugin/api",
|
||||
"go_version": "go1.24.0",
|
||||
"go_os": "linux",
|
||||
"go_arch": "amd64",
|
||||
"extension_points": [
|
||||
{ "type": "hook", "key": "upstream.connect/v1" }
|
||||
],
|
||||
"capabilities": {
|
||||
"upstream_connect": { "mode": "protocol-proxy" },
|
||||
"minecraft": {
|
||||
"protocol_versions": {
|
||||
"min": 47,
|
||||
"max": 767,
|
||||
"tested": [47, 760, 763, 767],
|
||||
"unsupported_policy": "kick"
|
||||
},
|
||||
"states": {
|
||||
"status": "transparent",
|
||||
"login": "handled",
|
||||
"configuration": "transparent",
|
||||
"play": "transparent"
|
||||
},
|
||||
"auth_modes": ["fixture"],
|
||||
"forwarding": {
|
||||
"supported": ["none", "velocity-modern"],
|
||||
"default": "none",
|
||||
"requires_secret": false
|
||||
},
|
||||
"unsupported_policy": "kick",
|
||||
"modded": {
|
||||
"forge": "transparent",
|
||||
"fabric": "transparent",
|
||||
"fml": "unsupported",
|
||||
"unknown": "pass"
|
||||
}
|
||||
}
|
||||
},
|
||||
"runtime_limits": {
|
||||
"handler_timeout_ms": 3000,
|
||||
"initial_write_timeout_ms": 1000
|
||||
},
|
||||
"events": [
|
||||
{ "name": "auth.success", "fields": ["result", "mode"] },
|
||||
{ "name": "auth.failure", "fields": ["result", "mode"] }
|
||||
],
|
||||
"custom_metrics": [
|
||||
{ "name": "auth.attempts", "type": "counter", "labels": ["result", "mode"] }
|
||||
],
|
||||
"external_dependencies": [
|
||||
{
|
||||
"name": "backend",
|
||||
"endpoint": "tcp://",
|
||||
"purpose": "auth",
|
||||
"required": true,
|
||||
"timeout": "3s",
|
||||
"retry": 0,
|
||||
"fail_policy": "fail_closed",
|
||||
"data_classes": ["operational"]
|
||||
}
|
||||
],
|
||||
"background_tasks": [
|
||||
{ "id": "profile-cache-gc", "name": "Profile cache GC", "mode": "manual", "manual": true, "timeout": "1s" }
|
||||
],
|
||||
"data_stores": [
|
||||
{ "name": "profile-cache", "schema_version": 1, "data_class": "profile_cache", "quota_bytes": 1048576, "retention": "24h", "exportable": false }
|
||||
],
|
||||
"file_stores": [
|
||||
{ "namespace": "cache", "data_class": "profile_cache", "quota_bytes": 1048576, "retention": "24h" },
|
||||
{ "namespace": "diagnostic", "data_class": "diagnostic", "quota_bytes": 1048576, "retention": "24h" }
|
||||
],
|
||||
"config_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"match_host": { "type": "string" },
|
||||
"fixture_accept": { "type": "boolean" },
|
||||
"disconnect_message": { "type": "string" },
|
||||
"backend": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
30
examples/plugins/upstream-rewrite/README.md
Normal file
30
examples/plugins/upstream-rewrite/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Upstream Rewrite Plugin
|
||||
|
||||
This example registers `upstream.connect/v1` in dialer mode. When `match_host`
|
||||
matches either the Minecraft hostname or the resolved upstream string, it dials
|
||||
the configured `upstream` and returns that connection. Non-matching connections
|
||||
return `api.ErrPass`.
|
||||
|
||||
Build and package:
|
||||
|
||||
```sh
|
||||
./build.sh
|
||||
```
|
||||
|
||||
The binary package is written to `dist/upstream-rewrite.mcgp`; the source
|
||||
package is written to `dist/upstream-rewrite-source.mcgp`.
|
||||
|
||||
Build the source package through the gateway builder:
|
||||
|
||||
```sh
|
||||
go run ../../../cmd/gateway plugin source-build dist/upstream-rewrite-source.mcgp dist/upstream-rewrite-built.mcgp
|
||||
```
|
||||
|
||||
Example config JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"match_host": "play.example",
|
||||
"upstream": "127.0.0.1:25566"
|
||||
}
|
||||
```
|
||||
23
examples/plugins/upstream-rewrite/build.sh
Executable file
23
examples/plugins/upstream-rewrite/build.sh
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
mkdir -p dist
|
||||
go build -buildmode=plugin -o dist/plugin.so .
|
||||
go run ./cmd/render-manifest > dist/manifest.json
|
||||
cp README.md dist/README.md
|
||||
(
|
||||
cd dist
|
||||
rm -f upstream-rewrite.mcgp
|
||||
zip -q upstream-rewrite.mcgp manifest.json plugin.so README.md
|
||||
)
|
||||
rm -rf dist/source-package
|
||||
mkdir -p dist/source-package/cmd/render-manifest
|
||||
ARTIFACT_TYPE=source go run ./cmd/render-manifest > dist/source-package/manifest.json
|
||||
cp main.go go.mod README.md dist/source-package/
|
||||
cp cmd/render-manifest/main.go dist/source-package/cmd/render-manifest/main.go
|
||||
go mod vendor -o dist/source-package/vendor
|
||||
(
|
||||
cd dist/source-package
|
||||
rm -f ../upstream-rewrite-source.mcgp
|
||||
zip -qr ../upstream-rewrite-source.mcgp manifest.json main.go go.mod README.md cmd/render-manifest/main.go vendor
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func main() {
|
||||
artifactType := os.Getenv("ARTIFACT_TYPE")
|
||||
if artifactType == "" {
|
||||
artifactType = "binary"
|
||||
}
|
||||
manifest := map[string]any{
|
||||
"schema_version": "mc-gateway.plugin/v1",
|
||||
"id": "upstream-rewrite",
|
||||
"name": "Upstream Rewrite",
|
||||
"version": "0.1.0",
|
||||
"description": "Rewrite selected upstream targets before dialing.",
|
||||
"artifact_type": artifactType,
|
||||
"runtime": map[string]any{
|
||||
"type": "go-plugin",
|
||||
"entry": "plugin.so",
|
||||
"entry_symbol": "Plugin",
|
||||
"metadata_symbol": "MCGatewayPluginMetadata",
|
||||
},
|
||||
"api_version": "plugin-api/v1",
|
||||
"sdk_module": "github.com/tursom/mc-gateway/plugin/api",
|
||||
"sdk_module_version": "v0.1.0",
|
||||
"go_version": runtime.Version(),
|
||||
"go_os": runtime.GOOS,
|
||||
"go_arch": runtime.GOARCH,
|
||||
"extension_points": []map[string]any{
|
||||
{"type": "hook", "key": "upstream.connect/v1"},
|
||||
},
|
||||
"capabilities": map[string]any{
|
||||
"extension_points": []string{"upstream.connect/v1"},
|
||||
"network": map[string]any{"outbound": []string{"tcp:*:*"}},
|
||||
"filesystem": map[string]any{"read": []string{}, "write": []string{}},
|
||||
"env": []string{},
|
||||
},
|
||||
"runtime_limits": map[string]any{
|
||||
"handler_timeout_ms": 3000,
|
||||
},
|
||||
"config_schema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"match_host": map[string]any{"type": "string"},
|
||||
"upstream": map[string]any{"type": "string"},
|
||||
},
|
||||
"required": []string{"upstream"},
|
||||
},
|
||||
}
|
||||
if artifactType == "source" {
|
||||
manifest["build"] = map[string]any{
|
||||
"type": "go",
|
||||
"entry": ".",
|
||||
"go_version": runtime.Version(),
|
||||
"cgo_enabled": true,
|
||||
"tags": []string{},
|
||||
"vendor_required": false,
|
||||
"output": "plugin.so",
|
||||
}
|
||||
}
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(manifest); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
7
examples/plugins/upstream-rewrite/go.mod
Normal file
7
examples/plugins/upstream-rewrite/go.mod
Normal file
@@ -0,0 +1,7 @@
|
||||
module github.com/tursom/mc-gateway/examples/plugins/upstream-rewrite
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require github.com/tursom/mc-gateway v0.0.0
|
||||
|
||||
replace github.com/tursom/mc-gateway => ../../..
|
||||
99
examples/plugins/upstream-rewrite/main.go
Normal file
99
examples/plugins/upstream-rewrite/main.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"runtime"
|
||||
|
||||
"github.com/tursom/mc-gateway/plugin/api"
|
||||
)
|
||||
|
||||
type PluginImpl struct {
|
||||
api.AbstractPlugin
|
||||
config Config
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
MatchHost string `json:"match_host"`
|
||||
Upstream string `json:"upstream"`
|
||||
}
|
||||
|
||||
func Plugin() api.Plugin {
|
||||
return &PluginImpl{}
|
||||
}
|
||||
|
||||
func MCGatewayPluginMetadata() string {
|
||||
return manifestJSON
|
||||
}
|
||||
|
||||
func (p *PluginImpl) NewConfigObj() any {
|
||||
return &Config{}
|
||||
}
|
||||
|
||||
func (p *PluginImpl) ReloadConfig(config any) error {
|
||||
if cfg, ok := config.(*Config); ok {
|
||||
p.config = *cfg
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PluginImpl) Init(gateway api.Gateway) error {
|
||||
return api.RegisterHookHandler(
|
||||
gateway,
|
||||
api.HookUpstreamConnect,
|
||||
func(req api.UpstreamConnectRequest) bool {
|
||||
return p.config.MatchHost == "" || req.Host == p.config.MatchHost || req.Upstream == p.config.MatchHost
|
||||
},
|
||||
func(req api.UpstreamConnectRequest) (net.Conn, error) {
|
||||
if p.config.Upstream == "" {
|
||||
return nil, api.ErrPass
|
||||
}
|
||||
if p.config.MatchHost != "" && req.Host != p.config.MatchHost && req.Upstream != p.config.MatchHost {
|
||||
return nil, api.ErrPass
|
||||
}
|
||||
return net.Dial("tcp", p.config.Upstream)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
var manifestJSON = compactJSON(map[string]any{
|
||||
"schema_version": "mc-gateway.plugin/v1",
|
||||
"id": "upstream-rewrite",
|
||||
"name": "Upstream Rewrite",
|
||||
"version": "0.1.0",
|
||||
"description": "Rewrite selected upstream targets before dialing.",
|
||||
"artifact_type": "binary",
|
||||
"runtime": map[string]any{
|
||||
"type": "go-plugin",
|
||||
"entry": "plugin.so",
|
||||
"entry_symbol": "Plugin",
|
||||
"metadata_symbol": "MCGatewayPluginMetadata",
|
||||
},
|
||||
"api_version": "plugin-api/v1",
|
||||
"sdk_module": "github.com/tursom/mc-gateway/plugin/api",
|
||||
"go_version": runtime.Version(),
|
||||
"go_os": runtime.GOOS,
|
||||
"go_arch": runtime.GOARCH,
|
||||
"extension_points": []map[string]any{
|
||||
{"type": "hook", "key": "upstream.connect/v1"},
|
||||
},
|
||||
"capabilities": map[string]any{
|
||||
"extension_points": []string{"upstream.connect/v1"},
|
||||
"network": map[string]any{"outbound": []string{"tcp:*:*"}},
|
||||
},
|
||||
"runtime_limits": map[string]any{
|
||||
"handler_timeout_ms": 3000,
|
||||
},
|
||||
"config_schema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"match_host": map[string]any{"type": "string"},
|
||||
"upstream": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
func compactJSON(value any) string {
|
||||
data, _ := json.Marshal(value)
|
||||
return string(data)
|
||||
}
|
||||
40
examples/plugins/upstream-rewrite/manifest.json
Normal file
40
examples/plugins/upstream-rewrite/manifest.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"schema_version": "mc-gateway.plugin/v1",
|
||||
"id": "upstream-rewrite",
|
||||
"name": "Upstream Rewrite",
|
||||
"version": "0.1.0",
|
||||
"description": "Rewrite selected upstream targets before dialing.",
|
||||
"artifact_type": "binary",
|
||||
"runtime": {
|
||||
"type": "go-plugin",
|
||||
"entry": "plugin.so",
|
||||
"entry_symbol": "Plugin",
|
||||
"metadata_symbol": "MCGatewayPluginMetadata"
|
||||
},
|
||||
"api_version": "plugin-api/v1",
|
||||
"sdk_module": "github.com/tursom/mc-gateway/plugin/api",
|
||||
"sdk_module_version": "v0.1.0",
|
||||
"go_version": "go1.24.4",
|
||||
"go_os": "linux",
|
||||
"go_arch": "amd64",
|
||||
"extension_points": [
|
||||
{ "type": "hook", "key": "upstream.connect/v1" }
|
||||
],
|
||||
"capabilities": {
|
||||
"extension_points": ["upstream.connect/v1"],
|
||||
"network": { "outbound": ["tcp:*:*"] },
|
||||
"filesystem": { "read": [], "write": [] },
|
||||
"env": []
|
||||
},
|
||||
"runtime_limits": {
|
||||
"handler_timeout_ms": 3000
|
||||
},
|
||||
"config_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"match_host": { "type": "string" },
|
||||
"upstream": { "type": "string" }
|
||||
},
|
||||
"required": ["upstream"]
|
||||
}
|
||||
}
|
||||
@@ -3,21 +3,23 @@ package adminaudit
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
const DefaultListLimit = 200
|
||||
|
||||
type Record struct {
|
||||
ID int64 `json:"id"`
|
||||
Actor string `json:"actor"`
|
||||
SourceIP string `json:"source_ip"`
|
||||
Action string `json:"action"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID string `json:"target_id"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
ID int64 `json:"id"`
|
||||
Actor string `json:"actor"`
|
||||
SourceIP string `json:"source_ip"`
|
||||
Action string `json:"action"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID string `json:"target_id"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
MetadataJSON string `json:"metadata_json"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type Repository struct {
|
||||
@@ -41,13 +43,25 @@ func NewRepositoryWithClock(db *sql.DB, now func() time.Time) Repository {
|
||||
}
|
||||
|
||||
func (r Repository) Record(ctx context.Context, actor, sourceIP, action, targetType, targetID string, success bool, message string) error {
|
||||
return r.RecordWithMetadata(ctx, actor, sourceIP, action, targetType, targetID, success, message, nil)
|
||||
}
|
||||
|
||||
func (r Repository) RecordWithMetadata(ctx context.Context, actor, sourceIP, action, targetType, targetID string, success bool, message string, metadata any) error {
|
||||
if r.db == nil {
|
||||
return nil
|
||||
}
|
||||
metadataJSON := "{}"
|
||||
if metadata != nil {
|
||||
data, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
metadataJSON = string(data)
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO audit_logs(actor, source_ip, action, target_type, target_id, success, message, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
actor, sourceIP, action, targetType, targetID, boolToInt(success), message, r.now().Unix())
|
||||
INSERT INTO audit_logs(actor, source_ip, action, target_type, target_id, success, message, metadata_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
actor, sourceIP, action, targetType, targetID, boolToInt(success), message, metadataJSON, r.now().Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -56,7 +70,7 @@ func (r Repository) List(ctx context.Context, limit int) ([]Record, error) {
|
||||
limit = DefaultListLimit
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, actor, source_ip, action, target_type, target_id, success, message, created_at
|
||||
SELECT id, actor, source_ip, action, target_type, target_id, success, message, metadata_json, created_at
|
||||
FROM audit_logs
|
||||
ORDER BY id DESC
|
||||
LIMIT ?`, limit)
|
||||
@@ -69,7 +83,7 @@ LIMIT ?`, limit)
|
||||
for rows.Next() {
|
||||
var item Record
|
||||
var success int
|
||||
if err := rows.Scan(&item.ID, &item.Actor, &item.SourceIP, &item.Action, &item.TargetType, &item.TargetID, &success, &item.Message, &item.CreatedAt); err != nil {
|
||||
if err := rows.Scan(&item.ID, &item.Actor, &item.SourceIP, &item.Action, &item.TargetType, &item.TargetID, &success, &item.Message, &item.MetadataJSON, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Success = success != 0
|
||||
|
||||
@@ -81,13 +81,345 @@ CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
target_id TEXT NOT NULL,
|
||||
success INTEGER NOT NULL,
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_artifacts (
|
||||
id TEXT PRIMARY KEY,
|
||||
plugin_id TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
sha256 TEXT NOT NULL UNIQUE,
|
||||
package_sha256 TEXT NOT NULL DEFAULT '',
|
||||
size_bytes INTEGER NOT NULL,
|
||||
artifact_type TEXT NOT NULL DEFAULT 'binary',
|
||||
runtime_type TEXT NOT NULL DEFAULT '',
|
||||
runtime_entry TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'uploaded',
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
capabilities_summary_json TEXT NOT NULL DEFAULT '{}',
|
||||
extension_points_json TEXT NOT NULL DEFAULT '[]',
|
||||
api_version TEXT NOT NULL DEFAULT '',
|
||||
go_version TEXT NOT NULL DEFAULT '',
|
||||
go_os TEXT NOT NULL DEFAULT '',
|
||||
go_arch TEXT NOT NULL DEFAULT '',
|
||||
uploaded_by TEXT NOT NULL DEFAULT '',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugins (
|
||||
id TEXT PRIMARY KEY,
|
||||
desired_artifact_id TEXT NOT NULL DEFAULT '',
|
||||
active_artifact_id TEXT NOT NULL DEFAULT '',
|
||||
loaded_artifact_id TEXT NOT NULL DEFAULT '',
|
||||
desired_state TEXT NOT NULL DEFAULT 'disabled',
|
||||
runtime_state TEXT NOT NULL DEFAULT 'not_loaded',
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
desired_generation INTEGER NOT NULL DEFAULT 1,
|
||||
applied_generation INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
runtime_summary_json TEXT NOT NULL DEFAULT '{}',
|
||||
dispatch_summary_json TEXT NOT NULL DEFAULT '{}',
|
||||
deleted_at INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
updated_by TEXT NOT NULL DEFAULT '',
|
||||
FOREIGN KEY (desired_artifact_id) REFERENCES plugin_artifacts(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_operations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plugin_id TEXT NOT NULL DEFAULT '',
|
||||
artifact_id TEXT NOT NULL DEFAULT '',
|
||||
operation TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
actor TEXT NOT NULL DEFAULT '',
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_builds (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plugin_id TEXT NOT NULL DEFAULT '',
|
||||
source_id TEXT NOT NULL DEFAULT '',
|
||||
artifact_id TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
builder_type TEXT NOT NULL DEFAULT '',
|
||||
builder_image TEXT NOT NULL DEFAULT '',
|
||||
builder_version TEXT NOT NULL DEFAULT '',
|
||||
go_version TEXT NOT NULL DEFAULT '',
|
||||
go_os TEXT NOT NULL DEFAULT '',
|
||||
go_arch TEXT NOT NULL DEFAULT '',
|
||||
go_amd64 TEXT NOT NULL DEFAULT '',
|
||||
go_arm64 TEXT NOT NULL DEFAULT '',
|
||||
cgo_enabled TEXT NOT NULL DEFAULT '',
|
||||
build_tags TEXT NOT NULL DEFAULT '',
|
||||
sdk_module TEXT NOT NULL DEFAULT '',
|
||||
sdk_version TEXT NOT NULL DEFAULT '',
|
||||
go_proxy TEXT NOT NULL DEFAULT '',
|
||||
go_no_sumdb TEXT NOT NULL DEFAULT '',
|
||||
go_private TEXT NOT NULL DEFAULT '',
|
||||
vendor_required INTEGER NOT NULL DEFAULT 0,
|
||||
source_sha256 TEXT NOT NULL DEFAULT '',
|
||||
artifact_sha256 TEXT NOT NULL DEFAULT '',
|
||||
module_summary_json TEXT NOT NULL DEFAULT '[]',
|
||||
go_version_m_json TEXT NOT NULL DEFAULT '{}',
|
||||
abi_fingerprint TEXT NOT NULL DEFAULT '',
|
||||
log_summary TEXT NOT NULL DEFAULT '',
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
started_at INTEGER NOT NULL DEFAULT 0,
|
||||
ended_at INTEGER NOT NULL DEFAULT 0,
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_config_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plugin_id TEXT NOT NULL,
|
||||
artifact_id TEXT NOT NULL DEFAULT '',
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
desired_state TEXT NOT NULL DEFAULT 'disabled',
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
desired_generation INTEGER NOT NULL,
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_secrets (
|
||||
plugin_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
current_version INTEGER NOT NULL DEFAULT 1,
|
||||
previous_version INTEGER NOT NULL DEFAULT 0,
|
||||
current_value TEXT NOT NULL DEFAULT '',
|
||||
previous_value TEXT NOT NULL DEFAULT '',
|
||||
reload_required INTEGER NOT NULL DEFAULT 0,
|
||||
hot_reload INTEGER NOT NULL DEFAULT 0,
|
||||
updated_by TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY(plugin_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_reviews (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plugin_id TEXT NOT NULL,
|
||||
artifact_id TEXT NOT NULL,
|
||||
profile TEXT NOT NULL DEFAULT 'dev',
|
||||
risk_level TEXT NOT NULL DEFAULT 'low',
|
||||
config_hash TEXT NOT NULL DEFAULT '',
|
||||
scope_hash TEXT NOT NULL DEFAULT '',
|
||||
rollout_hash TEXT NOT NULL DEFAULT '',
|
||||
runtime_limits_hash TEXT NOT NULL DEFAULT '',
|
||||
features_hash TEXT NOT NULL DEFAULT '',
|
||||
policy_hash TEXT NOT NULL DEFAULT '',
|
||||
decision TEXT NOT NULL DEFAULT 'approved',
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
reviewed_by TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_warning_overrides (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plugin_id TEXT NOT NULL,
|
||||
artifact_id TEXT NOT NULL,
|
||||
profile TEXT NOT NULL DEFAULT 'dev',
|
||||
action TEXT NOT NULL DEFAULT '',
|
||||
policy_hash TEXT NOT NULL DEFAULT '',
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_advisories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
advisory_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
action TEXT NOT NULL DEFAULT 'denylist',
|
||||
artifact_sha256 TEXT NOT NULL DEFAULT '',
|
||||
plugin_id TEXT NOT NULL DEFAULT '',
|
||||
version_range TEXT NOT NULL DEFAULT '',
|
||||
dependency_name TEXT NOT NULL DEFAULT '',
|
||||
dependency_range TEXT NOT NULL DEFAULT '',
|
||||
recommended_action TEXT NOT NULL DEFAULT '',
|
||||
fixed_version TEXT NOT NULL DEFAULT '',
|
||||
mitigation TEXT NOT NULL DEFAULT '',
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_preflight_results (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plugin_id TEXT NOT NULL,
|
||||
artifact_id TEXT NOT NULL,
|
||||
profile TEXT NOT NULL DEFAULT 'dev',
|
||||
status TEXT NOT NULL DEFAULT '',
|
||||
result_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_benchmarks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plugin_id TEXT NOT NULL,
|
||||
artifact_id TEXT NOT NULL,
|
||||
profile TEXT NOT NULL DEFAULT 'dev',
|
||||
benchmark_profile TEXT NOT NULL DEFAULT '',
|
||||
p95_ms REAL NOT NULL DEFAULT 0,
|
||||
p99_ms REAL NOT NULL DEFAULT 0,
|
||||
error_rate REAL NOT NULL DEFAULT 0,
|
||||
active_proxy_capacity INTEGER NOT NULL DEFAULT 0,
|
||||
baseline_diff REAL NOT NULL DEFAULT 0,
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plugin_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
fields_json TEXT NOT NULL DEFAULT '{}',
|
||||
dropped INTEGER NOT NULL DEFAULT 0,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
trace_id TEXT NOT NULL DEFAULT '',
|
||||
connection_id TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plugin_id TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
fields_json TEXT NOT NULL DEFAULT '{}',
|
||||
trace_id TEXT NOT NULL DEFAULT '',
|
||||
connection_id TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_traces (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plugin_id TEXT NOT NULL DEFAULT '',
|
||||
trace_id TEXT NOT NULL,
|
||||
connection_id TEXT NOT NULL DEFAULT '',
|
||||
handler_id TEXT NOT NULL DEFAULT '',
|
||||
operation TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT '',
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
fields_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_data (
|
||||
plugin_id TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value BLOB NOT NULL,
|
||||
schema_version INTEGER NOT NULL DEFAULT 0,
|
||||
data_class TEXT NOT NULL DEFAULT '',
|
||||
exportable INTEGER NOT NULL DEFAULT 0,
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY(plugin_id, key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_files (
|
||||
plugin_id TEXT NOT NULL,
|
||||
namespace TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
disk_path TEXT NOT NULL,
|
||||
data_class TEXT NOT NULL DEFAULT '',
|
||||
exportable INTEGER NOT NULL DEFAULT 0,
|
||||
readonly INTEGER NOT NULL DEFAULT 0,
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY(plugin_id, namespace, path)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_diagnostics (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plugin_id TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
sections_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_routes_enabled ON routes(enabled);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_artifacts_plugin_id ON plugin_artifacts(plugin_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugins_desired_state ON plugins(desired_state, priority);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_operations_plugin_id ON plugin_operations(plugin_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_builds_plugin_id ON plugin_builds(plugin_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_builds_source_id ON plugin_builds(source_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_config_snapshots_plugin_id ON plugin_config_snapshots(plugin_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_secrets_plugin_id ON plugin_secrets(plugin_id, updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_reviews_lookup ON plugin_reviews(plugin_id, artifact_id, profile, policy_hash, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_warning_overrides_lookup ON plugin_warning_overrides(plugin_id, artifact_id, profile, action, expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_advisories_artifact ON plugin_advisories(artifact_sha256, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_advisories_plugin ON plugin_advisories(plugin_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_preflight_lookup ON plugin_preflight_results(plugin_id, artifact_id, profile, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_benchmarks_lookup ON plugin_benchmarks(plugin_id, artifact_id, profile, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_events_lookup ON plugin_events(plugin_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_logs_lookup ON plugin_logs(plugin_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_traces_lookup ON plugin_traces(plugin_id, trace_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_data_expires ON plugin_data(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_files_expires ON plugin_files(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_diagnostics_lookup ON plugin_diagnostics(plugin_id, created_at);
|
||||
INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (1, strftime('%s','now'));
|
||||
`
|
||||
_, err := db.Exec(schema)
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureColumn(db, "audit_logs", "metadata_json", "TEXT NOT NULL DEFAULT '{}'"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureColumn(db, "plugin_secrets", "reload_required", "INTEGER NOT NULL DEFAULT 0"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureColumn(db, "plugin_secrets", "hot_reload", "INTEGER NOT NULL DEFAULT 0"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureColumn(db *sql.DB, table, column, definition string) error {
|
||||
rows, err := db.Query(`PRAGMA table_info(` + table + `)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, typ string
|
||||
var notNull int
|
||||
var defaultValue any
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬Null, &defaultValue, &pk); err != nil {
|
||||
return err
|
||||
}
|
||||
if name == column {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = db.Exec(`ALTER TABLE ` + table + ` ADD COLUMN ` + column + ` ` + definition)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -28,6 +28,26 @@ type APIHandlers struct {
|
||||
UserItem SegmentHandlerFunc
|
||||
|
||||
AuditLogs http.HandlerFunc
|
||||
|
||||
PluginArtifacts http.HandlerFunc
|
||||
PluginArtifact SegmentHandlerFunc
|
||||
PluginSources http.HandlerFunc
|
||||
PluginBuilds http.HandlerFunc
|
||||
PluginBuild SegmentHandlerFunc
|
||||
PluginGC http.HandlerFunc
|
||||
PluginsList http.HandlerFunc
|
||||
PluginItem SegmentHandlerFunc
|
||||
PluginAction SegmentHandlerFunc
|
||||
PluginConfig SegmentHandlerFunc
|
||||
PluginSecrets SegmentHandlerFunc
|
||||
PluginRollback SegmentHandlerFunc
|
||||
PluginOperations SegmentHandlerFunc
|
||||
PluginOperationsGC http.HandlerFunc
|
||||
PluginDraining SegmentHandlerFunc
|
||||
PluginDispatch http.HandlerFunc
|
||||
PluginGovernance SegmentHandlerFunc
|
||||
PluginAdvisories http.HandlerFunc
|
||||
PluginDiagnostics SegmentHandlerFunc
|
||||
}
|
||||
|
||||
func NewAPIHandler(prefix string, handlers APIHandlers) http.HandlerFunc {
|
||||
@@ -69,6 +89,65 @@ func NewAPIHandler(prefix string, handlers APIHandlers) http.HandlerFunc {
|
||||
callSegmentHandler(w, r, handlers.UserItem, strings.TrimPrefix(path, "/users/"))
|
||||
case path == "/audit-logs" && r.Method == http.MethodGet:
|
||||
callHandler(w, r, handlers.AuditLogs)
|
||||
case path == "/plugin-artifacts" && (r.Method == http.MethodGet || r.Method == http.MethodPost):
|
||||
callHandler(w, r, handlers.PluginArtifacts)
|
||||
case strings.HasPrefix(path, "/plugin-artifacts/"):
|
||||
callSegmentHandler(w, r, handlers.PluginArtifact, strings.TrimPrefix(path, "/plugin-artifacts/"))
|
||||
case path == "/plugin-sources" && (r.Method == http.MethodGet || r.Method == http.MethodPost):
|
||||
callHandler(w, r, handlers.PluginSources)
|
||||
case path == "/plugin-builds" && (r.Method == http.MethodGet || r.Method == http.MethodPost):
|
||||
callHandler(w, r, handlers.PluginBuilds)
|
||||
case strings.HasPrefix(path, "/plugin-builds/"):
|
||||
callSegmentHandler(w, r, handlers.PluginBuild, strings.TrimPrefix(path, "/plugin-builds/"))
|
||||
case path == "/plugin-gc" && (r.Method == http.MethodGet || r.Method == http.MethodPost):
|
||||
callHandler(w, r, handlers.PluginGC)
|
||||
case path == "/plugin-operations-gc" && (r.Method == http.MethodGet || r.Method == http.MethodPost):
|
||||
callHandler(w, r, handlers.PluginOperationsGC)
|
||||
case path == "/plugins" && r.Method == http.MethodGet:
|
||||
callHandler(w, r, handlers.PluginsList)
|
||||
case path == "/plugins/dispatch-plan" && r.Method == http.MethodGet:
|
||||
callHandler(w, r, handlers.PluginDispatch)
|
||||
case path == "/plugin-advisories" && (r.Method == http.MethodGet || r.Method == http.MethodPost):
|
||||
callHandler(w, r, handlers.PluginAdvisories)
|
||||
case strings.HasPrefix(path, "/plugins/"):
|
||||
pluginPath := strings.TrimPrefix(path, "/plugins/")
|
||||
if strings.Contains(pluginPath, "/governance/") || strings.HasSuffix(pluginPath, "/governance") {
|
||||
callSegmentHandler(w, r, handlers.PluginGovernance, pluginPath)
|
||||
return
|
||||
}
|
||||
if strings.Contains(pluginPath, "/operations/") || strings.HasSuffix(pluginPath, "/operations") {
|
||||
callSegmentHandler(w, r, handlers.PluginOperations, pluginPath)
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(pluginPath, "/diagnostics") {
|
||||
callSegmentHandler(w, r, handlers.PluginDiagnostics, strings.TrimSuffix(pluginPath, "/diagnostics"))
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(pluginPath, "/draining/force-close") {
|
||||
callSegmentHandler(w, r, handlers.PluginDraining, strings.TrimSuffix(pluginPath, "/draining/force-close"))
|
||||
return
|
||||
}
|
||||
if strings.Contains(pluginPath, "/rollback/") || strings.HasSuffix(pluginPath, "/rollback") {
|
||||
callSegmentHandler(w, r, handlers.PluginRollback, pluginPath)
|
||||
return
|
||||
}
|
||||
if strings.Contains(pluginPath, "/config/") || strings.HasSuffix(pluginPath, "/config") {
|
||||
callSegmentHandler(w, r, handlers.PluginConfig, pluginPath)
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(pluginPath, "/proxy-connections") {
|
||||
callSegmentHandler(w, r, handlers.PluginItem, pluginPath)
|
||||
return
|
||||
}
|
||||
if strings.Contains(pluginPath, "/secrets/") || strings.HasSuffix(pluginPath, "/secrets") {
|
||||
callSegmentHandler(w, r, handlers.PluginSecrets, pluginPath)
|
||||
return
|
||||
}
|
||||
if strings.Count(pluginPath, "/") == 1 {
|
||||
callSegmentHandler(w, r, handlers.PluginAction, pluginPath)
|
||||
return
|
||||
}
|
||||
callSegmentHandler(w, r, handlers.PluginItem, pluginPath)
|
||||
default:
|
||||
WriteAPIError(w, http.StatusNotFound, "not found")
|
||||
}
|
||||
|
||||
@@ -30,6 +30,29 @@ func TestNewAPIHandlerRoutesRequests(t *testing.T) {
|
||||
{name: "users create", method: http.MethodPost, path: "/admin/api/users", wantCall: "users_create"},
|
||||
{name: "user item", method: http.MethodPatch, path: "/admin/api/users/member", wantCall: "user_item", wantSegment: "member"},
|
||||
{name: "audit logs", method: http.MethodGet, path: "/admin/api/audit-logs", wantCall: "audit_logs"},
|
||||
{name: "plugin artifacts", method: http.MethodGet, path: "/admin/api/plugin-artifacts", wantCall: "plugin_artifacts"},
|
||||
{name: "plugin artifact", method: http.MethodGet, path: "/admin/api/plugin-artifacts/abc", wantCall: "plugin_artifact", wantSegment: "abc"},
|
||||
{name: "plugin sources", method: http.MethodGet, path: "/admin/api/plugin-sources", wantCall: "plugin_sources"},
|
||||
{name: "plugin builds", method: http.MethodGet, path: "/admin/api/plugin-builds", wantCall: "plugin_builds"},
|
||||
{name: "plugin build", method: http.MethodGet, path: "/admin/api/plugin-builds/7", wantCall: "plugin_build", wantSegment: "7"},
|
||||
{name: "plugin build retry", method: http.MethodPost, path: "/admin/api/plugin-builds/7/retry", wantCall: "plugin_build", wantSegment: "7/retry"},
|
||||
{name: "plugin gc", method: http.MethodGet, path: "/admin/api/plugin-gc", wantCall: "plugin_gc"},
|
||||
{name: "plugin operations gc", method: http.MethodGet, path: "/admin/api/plugin-operations-gc", wantCall: "plugin_operations_gc"},
|
||||
{name: "plugins list", method: http.MethodGet, path: "/admin/api/plugins", wantCall: "plugins_list"},
|
||||
{name: "plugin item", method: http.MethodPut, path: "/admin/api/plugins/upstream-rewrite", wantCall: "plugin_item", wantSegment: "upstream-rewrite"},
|
||||
{name: "plugin action", method: http.MethodPost, path: "/admin/api/plugins/upstream-rewrite/enable", wantCall: "plugin_action", wantSegment: "upstream-rewrite/enable"},
|
||||
{name: "plugin config", method: http.MethodPut, path: "/admin/api/plugins/upstream-rewrite/config", wantCall: "plugin_config", wantSegment: "upstream-rewrite/config"},
|
||||
{name: "plugin config dry-run", method: http.MethodPost, path: "/admin/api/plugins/upstream-rewrite/config/dry-run", wantCall: "plugin_config", wantSegment: "upstream-rewrite/config/dry-run"},
|
||||
{name: "plugin secrets", method: http.MethodGet, path: "/admin/api/plugins/upstream-rewrite/secrets", wantCall: "plugin_secrets", wantSegment: "upstream-rewrite/secrets"},
|
||||
{name: "plugin rollback artifact", method: http.MethodPost, path: "/admin/api/plugins/upstream-rewrite/rollback/artifact", wantCall: "plugin_rollback", wantSegment: "upstream-rewrite/rollback/artifact"},
|
||||
{name: "plugin governance", method: http.MethodGet, path: "/admin/api/plugins/upstream-rewrite/governance", wantCall: "plugin_governance", wantSegment: "upstream-rewrite/governance"},
|
||||
{name: "plugin governance review", method: http.MethodPost, path: "/admin/api/plugins/upstream-rewrite/governance/review", wantCall: "plugin_governance", wantSegment: "upstream-rewrite/governance/review"},
|
||||
{name: "plugin operations", method: http.MethodGet, path: "/admin/api/plugins/upstream-rewrite/operations", wantCall: "plugin_operations", wantSegment: "upstream-rewrite/operations"},
|
||||
{name: "plugin operations task trigger", method: http.MethodPost, path: "/admin/api/plugins/upstream-rewrite/operations/tasks/sync/trigger", wantCall: "plugin_operations", wantSegment: "upstream-rewrite/operations/tasks/sync/trigger"},
|
||||
{name: "plugin diagnostics", method: http.MethodGet, path: "/admin/api/plugins/upstream-rewrite/diagnostics", wantCall: "plugin_diagnostics", wantSegment: "upstream-rewrite"},
|
||||
{name: "plugin draining force close", method: http.MethodPost, path: "/admin/api/plugins/mc-auth-proxy/draining/force-close", wantCall: "plugin_draining", wantSegment: "mc-auth-proxy"},
|
||||
{name: "plugin dispatch", method: http.MethodGet, path: "/admin/api/plugins/dispatch-plan", wantCall: "plugin_dispatch"},
|
||||
{name: "plugin advisories", method: http.MethodGet, path: "/admin/api/plugin-advisories", wantCall: "plugin_advisories"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -56,6 +79,26 @@ func TestNewAPIHandlerRoutesRequests(t *testing.T) {
|
||||
UserItem: recordSegmentCall(&gotCall, &gotSegment, "user_item"),
|
||||
|
||||
AuditLogs: recordCall(&gotCall, "audit_logs"),
|
||||
|
||||
PluginArtifacts: recordCall(&gotCall, "plugin_artifacts"),
|
||||
PluginArtifact: recordSegmentCall(&gotCall, &gotSegment, "plugin_artifact"),
|
||||
PluginSources: recordCall(&gotCall, "plugin_sources"),
|
||||
PluginBuilds: recordCall(&gotCall, "plugin_builds"),
|
||||
PluginBuild: recordSegmentCall(&gotCall, &gotSegment, "plugin_build"),
|
||||
PluginGC: recordCall(&gotCall, "plugin_gc"),
|
||||
PluginOperationsGC: recordCall(&gotCall, "plugin_operations_gc"),
|
||||
PluginsList: recordCall(&gotCall, "plugins_list"),
|
||||
PluginItem: recordSegmentCall(&gotCall, &gotSegment, "plugin_item"),
|
||||
PluginAction: recordSegmentCall(&gotCall, &gotSegment, "plugin_action"),
|
||||
PluginConfig: recordSegmentCall(&gotCall, &gotSegment, "plugin_config"),
|
||||
PluginSecrets: recordSegmentCall(&gotCall, &gotSegment, "plugin_secrets"),
|
||||
PluginRollback: recordSegmentCall(&gotCall, &gotSegment, "plugin_rollback"),
|
||||
PluginOperations: recordSegmentCall(&gotCall, &gotSegment, "plugin_operations"),
|
||||
PluginDraining: recordSegmentCall(&gotCall, &gotSegment, "plugin_draining"),
|
||||
PluginDispatch: recordCall(&gotCall, "plugin_dispatch"),
|
||||
PluginGovernance: recordSegmentCall(&gotCall, &gotSegment, "plugin_governance"),
|
||||
PluginAdvisories: recordCall(&gotCall, "plugin_advisories"),
|
||||
PluginDiagnostics: recordSegmentCall(&gotCall, &gotSegment, "plugin_diagnostics"),
|
||||
})
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
@@ -34,3 +34,33 @@ type PatchUserRequest struct {
|
||||
Password *string `json:"password"`
|
||||
Disabled *bool `json:"disabled"`
|
||||
}
|
||||
|
||||
type PluginDesiredRequest struct {
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
DesiredState string `json:"desired_state"`
|
||||
Priority int `json:"priority"`
|
||||
Config map[string]any `json:"config"`
|
||||
ConfigJSON string `json:"config_json"`
|
||||
}
|
||||
|
||||
type PluginConfigRequest struct {
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
DesiredState string `json:"desired_state"`
|
||||
Priority int `json:"priority"`
|
||||
Config map[string]any `json:"config"`
|
||||
ConfigJSON string `json:"config_json"`
|
||||
}
|
||||
|
||||
type PluginSecretRequest struct {
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
ReloadRequired bool `json:"reload_required"`
|
||||
HotReload bool `json:"hot_reload"`
|
||||
}
|
||||
|
||||
type PluginRollbackRequest struct {
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
SnapshotID int64 `json:"snapshot_id"`
|
||||
FullDesired bool `json:"full_desired"`
|
||||
}
|
||||
|
||||
@@ -68,6 +68,8 @@ func Permissions(role string) map[string]bool {
|
||||
"read_routes": HasRole(role, RoleGuest),
|
||||
"write_routes": HasRole(role, RoleMember),
|
||||
"read_status": HasRole(role, RoleMember),
|
||||
"read_plugins": HasRole(role, RoleMember),
|
||||
"manage_plugins": HasRole(role, RoleAdmin),
|
||||
"manage_users": HasRole(role, RoleAdmin),
|
||||
"manage_services": HasRole(role, RoleAdmin),
|
||||
}
|
||||
|
||||
@@ -82,6 +82,8 @@ func TestPermissions(t *testing.T) {
|
||||
"read_routes": true,
|
||||
"write_routes": false,
|
||||
"read_status": false,
|
||||
"read_plugins": false,
|
||||
"manage_plugins": false,
|
||||
"manage_users": false,
|
||||
"manage_services": false,
|
||||
}
|
||||
@@ -93,6 +95,8 @@ func TestPermissions(t *testing.T) {
|
||||
"read_routes": true,
|
||||
"write_routes": true,
|
||||
"read_status": true,
|
||||
"read_plugins": true,
|
||||
"manage_plugins": true,
|
||||
"manage_users": true,
|
||||
"manage_services": true,
|
||||
}
|
||||
|
||||
702
internal/pluginmanager/artifact.go
Normal file
702
internal/pluginmanager/artifact.go
Normal file
@@ -0,0 +1,702 @@
|
||||
package pluginmanager
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
pluginIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,127}$`)
|
||||
secretNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
|
||||
schemaKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`)
|
||||
)
|
||||
|
||||
type ArtifactStore struct {
|
||||
Root string
|
||||
MaxPackageBytes int64
|
||||
MaxManifestBytes int64
|
||||
MaxEntries int
|
||||
MaxExtractedBytes int64
|
||||
MaxNonRuntimeBytes int64
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type ArtifactUpload struct {
|
||||
SourcePath string
|
||||
FileName string
|
||||
Actor string
|
||||
}
|
||||
|
||||
func NewArtifactStore(root string) ArtifactStore {
|
||||
return ArtifactStore{
|
||||
Root: root,
|
||||
MaxPackageBytes: DefaultPackageMaxBytes,
|
||||
MaxManifestBytes: DefaultManifestMaxBytes,
|
||||
MaxEntries: DefaultPackageMaxEntries,
|
||||
MaxExtractedBytes: DefaultExtractedMaxBytes,
|
||||
MaxNonRuntimeBytes: DefaultNonRuntimeMaxBytes,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
func (s ArtifactStore) ValidateAndStore(upload ArtifactUpload) (ArtifactRecord, error) {
|
||||
return s.validateAndStore(upload, "")
|
||||
}
|
||||
|
||||
func (s ArtifactStore) ValidateAndStoreBinary(upload ArtifactUpload) (ArtifactRecord, error) {
|
||||
return s.validateAndStore(upload, ArtifactTypeBinary)
|
||||
}
|
||||
|
||||
func (s ArtifactStore) ValidateAndStoreSource(upload ArtifactUpload) (ArtifactRecord, error) {
|
||||
return s.validateAndStore(upload, ArtifactTypeSource)
|
||||
}
|
||||
|
||||
func (s ArtifactStore) StoreBuiltBinary(upload ArtifactUpload, manifest Manifest, pluginBytes []byte, packageSHA string, metadata map[string]any) (ArtifactRecord, error) {
|
||||
if s.Root == "" {
|
||||
return ArtifactRecord{}, errors.New("plugin artifact root is empty")
|
||||
}
|
||||
if s.now == nil {
|
||||
s.now = time.Now
|
||||
}
|
||||
manifest.ArtifactType = ArtifactTypeBinary
|
||||
manifest.Runtime.Entry = RuntimeEntry
|
||||
if err := validateManifest(manifest); err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
if len(pluginBytes) == 0 {
|
||||
return ArtifactRecord{}, errors.New("built runtime entry is empty")
|
||||
}
|
||||
pluginSum := sha256.Sum256(pluginBytes)
|
||||
artifactID := hex.EncodeToString(pluginSum[:])
|
||||
artifactDir := filepath.Join(s.Root, manifest.ID, artifactID)
|
||||
if err := os.MkdirAll(artifactDir, 0755); err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
pluginPath := filepath.Join(artifactDir, RuntimeEntry)
|
||||
if err := os.WriteFile(pluginPath, pluginBytes, 0644); err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
manifestBytes, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(artifactDir, "manifest.json"), manifestBytes, 0644); err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
if len(metadata) > 0 {
|
||||
provenance, err := json.MarshalIndent(metadata, "", " ")
|
||||
if err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(artifactDir, "provenance.json"), provenance, 0644); err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
}
|
||||
extensionPoints, err := json.Marshal(extensionPointKeys(manifest))
|
||||
if err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
capabilities, err := manifestCapabilitiesSummaryJSON(manifest)
|
||||
if err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
now := s.now().Unix()
|
||||
return ArtifactRecord{
|
||||
ID: artifactID,
|
||||
PluginID: manifest.ID,
|
||||
Version: manifest.Version,
|
||||
FileName: upload.FileName,
|
||||
FilePath: pluginPath,
|
||||
SHA256: artifactID,
|
||||
PackageSHA256: packageSHA,
|
||||
SizeBytes: int64(len(pluginBytes)),
|
||||
ArtifactType: ArtifactTypeBinary,
|
||||
RuntimeType: manifest.Runtime.Type,
|
||||
RuntimeEntry: manifest.Runtime.Entry,
|
||||
Status: ArtifactStatusLoadable,
|
||||
MetadataJSON: string(manifestBytes),
|
||||
CapabilitiesSummaryJSON: string(capabilities),
|
||||
ExtensionPointsJSON: string(extensionPoints),
|
||||
APIVersion: manifest.APIVersion,
|
||||
GoVersion: manifest.GoVersion,
|
||||
GOOS: manifest.GOOS,
|
||||
GOARCH: manifest.GOARCH,
|
||||
UploadedBy: upload.Actor,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s ArtifactStore) validateAndStore(upload ArtifactUpload, expectedArtifactType string) (ArtifactRecord, error) {
|
||||
if s.Root == "" {
|
||||
return ArtifactRecord{}, errors.New("plugin artifact root is empty")
|
||||
}
|
||||
if s.MaxPackageBytes <= 0 {
|
||||
s.MaxPackageBytes = DefaultPackageMaxBytes
|
||||
}
|
||||
if s.MaxManifestBytes <= 0 {
|
||||
s.MaxManifestBytes = DefaultManifestMaxBytes
|
||||
}
|
||||
if s.MaxEntries <= 0 {
|
||||
s.MaxEntries = DefaultPackageMaxEntries
|
||||
}
|
||||
if s.MaxExtractedBytes <= 0 {
|
||||
s.MaxExtractedBytes = DefaultExtractedMaxBytes
|
||||
}
|
||||
if s.MaxNonRuntimeBytes <= 0 {
|
||||
s.MaxNonRuntimeBytes = DefaultNonRuntimeMaxBytes
|
||||
}
|
||||
if s.now == nil {
|
||||
s.now = time.Now
|
||||
}
|
||||
|
||||
info, err := os.Stat(upload.SourcePath)
|
||||
if err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
if info.Size() <= 0 {
|
||||
return ArtifactRecord{}, errors.New("plugin package is empty")
|
||||
}
|
||||
if info.Size() > s.MaxPackageBytes {
|
||||
return ArtifactRecord{}, fmt.Errorf("plugin package size %d exceeds limit %d", info.Size(), s.MaxPackageBytes)
|
||||
}
|
||||
|
||||
packageSHA, err := fileSHA256(upload.SourcePath)
|
||||
if err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
|
||||
reader, err := zip.OpenReader(upload.SourcePath)
|
||||
if err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
defer reader.Close()
|
||||
if len(reader.File) > s.MaxEntries {
|
||||
return ArtifactRecord{}, fmt.Errorf("plugin package has %d entries, exceeds limit %d", len(reader.File), s.MaxEntries)
|
||||
}
|
||||
|
||||
var manifestFile *zip.File
|
||||
entries := make(map[string]*zip.File)
|
||||
var extractedSize uint64
|
||||
for _, file := range reader.File {
|
||||
if file.FileInfo().IsDir() {
|
||||
if _, err := cleanZipDirName(file.Name); err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
clean, err := cleanZipName(file.Name)
|
||||
if err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
mode := file.FileInfo().Mode()
|
||||
if !mode.IsRegular() || mode&os.ModeType != 0 {
|
||||
return ArtifactRecord{}, fmt.Errorf("unsupported zip entry type %q", file.Name)
|
||||
}
|
||||
if _, exists := entries[clean]; exists {
|
||||
return ArtifactRecord{}, fmt.Errorf("duplicate zip entry %q", clean)
|
||||
}
|
||||
extractedSize += file.UncompressedSize64
|
||||
if extractedSize > uint64(s.MaxExtractedBytes) {
|
||||
return ArtifactRecord{}, fmt.Errorf("plugin package extracted size exceeds limit %d", s.MaxExtractedBytes)
|
||||
}
|
||||
entries[clean] = file
|
||||
if clean == "manifest.json" {
|
||||
manifestFile = file
|
||||
}
|
||||
}
|
||||
if manifestFile == nil {
|
||||
return ArtifactRecord{}, errors.New("manifest.json is required")
|
||||
}
|
||||
if manifestFile.UncompressedSize64 > uint64(s.MaxManifestBytes) {
|
||||
return ArtifactRecord{}, fmt.Errorf("manifest.json size %d exceeds limit %d", manifestFile.UncompressedSize64, s.MaxManifestBytes)
|
||||
}
|
||||
|
||||
manifestBytes, err := readZipFile(manifestFile, s.MaxManifestBytes)
|
||||
if err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
var manifest Manifest
|
||||
if err := json.Unmarshal(manifestBytes, &manifest); err != nil {
|
||||
return ArtifactRecord{}, fmt.Errorf("invalid manifest.json: %w", err)
|
||||
}
|
||||
if err := validateManifest(manifest); err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
if expectedArtifactType != "" && manifest.ArtifactType != expectedArtifactType {
|
||||
return ArtifactRecord{}, fmt.Errorf("artifact_type %q does not match expected %q", manifest.ArtifactType, expectedArtifactType)
|
||||
}
|
||||
if manifest.ArtifactType == ArtifactTypeSource {
|
||||
return s.storeSourcePackage(upload, manifest, manifestBytes, entries, packageSHA)
|
||||
}
|
||||
|
||||
entry := manifest.Runtime.Entry
|
||||
pluginFile, ok := entries[entry]
|
||||
if !ok {
|
||||
return ArtifactRecord{}, fmt.Errorf("runtime entry %q is required", entry)
|
||||
}
|
||||
if pluginFile.UncompressedSize64 == 0 {
|
||||
return ArtifactRecord{}, errors.New("runtime entry is empty")
|
||||
}
|
||||
if pluginFile.UncompressedSize64 > uint64(s.MaxPackageBytes) {
|
||||
return ArtifactRecord{}, fmt.Errorf("runtime entry size %d exceeds limit %d", pluginFile.UncompressedSize64, s.MaxPackageBytes)
|
||||
}
|
||||
for name, file := range entries {
|
||||
if name == "manifest.json" || name == entry {
|
||||
continue
|
||||
}
|
||||
if file.UncompressedSize64 > uint64(s.MaxNonRuntimeBytes) {
|
||||
return ArtifactRecord{}, fmt.Errorf("zip entry %q size %d exceeds limit %d", name, file.UncompressedSize64, s.MaxNonRuntimeBytes)
|
||||
}
|
||||
}
|
||||
|
||||
pluginBytes, err := readZipFile(pluginFile, s.MaxPackageBytes)
|
||||
if err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
pluginSum := sha256.Sum256(pluginBytes)
|
||||
artifactID := hex.EncodeToString(pluginSum[:])
|
||||
artifactDir := filepath.Join(s.Root, manifest.ID, artifactID)
|
||||
if err := os.MkdirAll(artifactDir, 0755); err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
pluginPath := filepath.Join(artifactDir, RuntimeEntry)
|
||||
if err := os.WriteFile(pluginPath, pluginBytes, 0644); err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(artifactDir, "manifest.json"), manifestBytes, 0644); err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
|
||||
metadataJSON, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
extensionPoints, err := json.Marshal(extensionPointKeys(manifest))
|
||||
if err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
capabilities, err := manifestCapabilitiesSummaryJSON(manifest)
|
||||
if err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
now := s.now().Unix()
|
||||
return ArtifactRecord{
|
||||
ID: artifactID,
|
||||
PluginID: manifest.ID,
|
||||
Version: manifest.Version,
|
||||
FileName: upload.FileName,
|
||||
FilePath: pluginPath,
|
||||
SHA256: artifactID,
|
||||
PackageSHA256: packageSHA,
|
||||
SizeBytes: int64(len(pluginBytes)),
|
||||
ArtifactType: manifest.ArtifactType,
|
||||
RuntimeType: manifest.Runtime.Type,
|
||||
RuntimeEntry: manifest.Runtime.Entry,
|
||||
Status: ArtifactStatusLoadable,
|
||||
MetadataJSON: string(metadataJSON),
|
||||
CapabilitiesSummaryJSON: string(capabilities),
|
||||
ExtensionPointsJSON: string(extensionPoints),
|
||||
APIVersion: manifest.APIVersion,
|
||||
GoVersion: manifest.GoVersion,
|
||||
GOOS: manifest.GOOS,
|
||||
GOARCH: manifest.GOARCH,
|
||||
UploadedBy: upload.Actor,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s ArtifactStore) storeSourcePackage(upload ArtifactUpload, manifest Manifest, manifestBytes []byte, entries map[string]*zip.File, packageSHA string) (ArtifactRecord, error) {
|
||||
if err := validateSourceEntries(manifest, entries); err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
artifactID := packageSHA
|
||||
artifactDir := filepath.Join(s.Root, manifest.ID, artifactID)
|
||||
sourceDir := filepath.Join(artifactDir, "source")
|
||||
if err := os.MkdirAll(sourceDir, 0755); err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
if err := copyFile(upload.SourcePath, filepath.Join(artifactDir, "source.mcgp")); err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
var sizeBytes int64
|
||||
for name, file := range entries {
|
||||
if name == "manifest.json" {
|
||||
continue
|
||||
}
|
||||
if file.UncompressedSize64 > uint64(s.MaxNonRuntimeBytes) && !strings.HasPrefix(name, "vendor/") {
|
||||
return ArtifactRecord{}, fmt.Errorf("zip entry %q size %d exceeds limit %d", name, file.UncompressedSize64, s.MaxNonRuntimeBytes)
|
||||
}
|
||||
target := filepath.Join(sourceDir, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
data, err := readZipFile(file, s.MaxExtractedBytes)
|
||||
if err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
sizeBytes += int64(len(data))
|
||||
if err := os.WriteFile(target, data, 0644); err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(artifactDir, "manifest.json"), manifestBytes, 0644); err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
metadataJSON, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
extensionPoints, err := json.Marshal(extensionPointKeys(manifest))
|
||||
if err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
capabilities, err := manifestCapabilitiesSummaryJSON(manifest)
|
||||
if err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
now := s.now().Unix()
|
||||
return ArtifactRecord{
|
||||
ID: artifactID,
|
||||
PluginID: manifest.ID,
|
||||
Version: manifest.Version,
|
||||
FileName: upload.FileName,
|
||||
FilePath: sourceDir,
|
||||
SHA256: artifactID,
|
||||
PackageSHA256: packageSHA,
|
||||
SizeBytes: sizeBytes,
|
||||
ArtifactType: ArtifactTypeSource,
|
||||
RuntimeType: manifest.Runtime.Type,
|
||||
RuntimeEntry: sourceBuildEntry(manifest),
|
||||
Status: ArtifactStatusValidated,
|
||||
MetadataJSON: string(metadataJSON),
|
||||
CapabilitiesSummaryJSON: string(capabilities),
|
||||
ExtensionPointsJSON: string(extensionPoints),
|
||||
APIVersion: manifest.APIVersion,
|
||||
GoVersion: manifest.GoVersion,
|
||||
GOOS: manifest.GOOS,
|
||||
GOARCH: manifest.GOARCH,
|
||||
UploadedBy: upload.Actor,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func capabilitiesSummaryJSON(raw json.RawMessage) ([]byte, error) {
|
||||
summary := CapabilitySummary{
|
||||
UpstreamConnect: UpstreamConnectCapability{Mode: UpstreamModeDialer},
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return json.Marshal(summary)
|
||||
}
|
||||
summary.Raw = append(json.RawMessage(nil), raw...)
|
||||
var caps struct {
|
||||
UpstreamConnect UpstreamConnectCapability `json:"upstream_connect"`
|
||||
Minecraft *MinecraftCapability `json:"minecraft"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &caps); err != nil {
|
||||
return nil, fmt.Errorf("invalid capabilities: %w", err)
|
||||
}
|
||||
if caps.UpstreamConnect.Mode != "" {
|
||||
summary.UpstreamConnect.Mode = caps.UpstreamConnect.Mode
|
||||
}
|
||||
if caps.Minecraft != nil {
|
||||
summary.Minecraft = caps.Minecraft
|
||||
if summary.Minecraft.UnsupportedPolicy == "" {
|
||||
summary.Minecraft.UnsupportedPolicy = summary.Minecraft.ProtocolVersions.UnsupportedPolicy
|
||||
}
|
||||
}
|
||||
switch summary.UpstreamConnect.Mode {
|
||||
case UpstreamModeDialer, UpstreamModeProtocolProxy:
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported upstream_connect.mode %q", summary.UpstreamConnect.Mode)
|
||||
}
|
||||
return json.Marshal(summary)
|
||||
}
|
||||
|
||||
func manifestCapabilitiesSummaryJSON(manifest Manifest) ([]byte, error) {
|
||||
data, err := capabilitiesSummaryJSON(manifest.Capabilities)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var summary CapabilitySummary
|
||||
if err := json.Unmarshal(data, &summary); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary.Events = append([]EventSpec(nil), manifest.Events...)
|
||||
summary.CustomMetrics = append([]MetricSpec(nil), manifest.CustomMetrics...)
|
||||
summary.ExternalDeps = append([]ExternalSpec(nil), manifest.ExternalDeps...)
|
||||
summary.DataStores = append([]DataStoreSpec(nil), manifest.DataStores...)
|
||||
summary.FileStores = append([]FileStoreSpec(nil), manifest.FileStores...)
|
||||
return json.Marshal(summary)
|
||||
}
|
||||
|
||||
func validateManifest(manifest Manifest) error {
|
||||
switch {
|
||||
case manifest.SchemaVersion != SchemaVersion:
|
||||
return fmt.Errorf("unsupported schema_version %q", manifest.SchemaVersion)
|
||||
case !pluginIDPattern.MatchString(manifest.ID):
|
||||
return fmt.Errorf("invalid plugin id %q", manifest.ID)
|
||||
case strings.TrimSpace(manifest.Version) == "":
|
||||
return errors.New("version is required")
|
||||
case manifest.ArtifactType != ArtifactTypeBinary && manifest.ArtifactType != ArtifactTypeSource:
|
||||
return fmt.Errorf("unsupported artifact_type %q", manifest.ArtifactType)
|
||||
case manifest.Runtime.Type != RuntimeGoPlugin:
|
||||
return fmt.Errorf("unsupported runtime.type %q", manifest.Runtime.Type)
|
||||
case manifest.ArtifactType == ArtifactTypeBinary && manifest.Runtime.Entry != RuntimeEntry:
|
||||
return fmt.Errorf("unsupported runtime.entry %q", manifest.Runtime.Entry)
|
||||
case manifest.ArtifactType == ArtifactTypeSource && rawSourceBuildEntry(manifest) == "":
|
||||
return errors.New("build.entry is required for source artifacts")
|
||||
case manifest.APIVersion != APIVersion:
|
||||
return fmt.Errorf("unsupported api_version %q", manifest.APIVersion)
|
||||
case manifest.ArtifactType == ArtifactTypeBinary && manifest.GoVersion == "":
|
||||
return errors.New("go_version is required")
|
||||
case manifest.ArtifactType == ArtifactTypeBinary && manifest.GOOS == "":
|
||||
return errors.New("go_os is required")
|
||||
case manifest.ArtifactType == ArtifactTypeBinary && manifest.GOARCH == "":
|
||||
return errors.New("go_arch is required")
|
||||
}
|
||||
if manifest.ArtifactType == ArtifactTypeBinary && manifest.GOOS != "" && manifest.GOOS != runtime.GOOS {
|
||||
return fmt.Errorf("go_os %q does not match gateway %q", manifest.GOOS, runtime.GOOS)
|
||||
}
|
||||
if manifest.ArtifactType == ArtifactTypeBinary && manifest.GOARCH != "" && manifest.GOARCH != runtime.GOARCH {
|
||||
return fmt.Errorf("go_arch %q does not match gateway %q", manifest.GOARCH, runtime.GOARCH)
|
||||
}
|
||||
found := false
|
||||
for _, ep := range manifest.ExtensionPoints {
|
||||
if ep.Type == "hook" && ep.Key == ExtensionUpstreamConnect {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("extension point %q is required", ExtensionUpstreamConnect)
|
||||
}
|
||||
seenSecrets := make(map[string]bool, len(manifest.Secrets))
|
||||
for _, secret := range manifest.Secrets {
|
||||
if !secretNamePattern.MatchString(secret.Name) {
|
||||
return fmt.Errorf("invalid secret name %q", secret.Name)
|
||||
}
|
||||
if seenSecrets[secret.Name] {
|
||||
return fmt.Errorf("duplicate secret name %q", secret.Name)
|
||||
}
|
||||
seenSecrets[secret.Name] = true
|
||||
}
|
||||
if err := validateNamedSpecs("event", eventSpecNames(manifest.Events)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateNamedSpecs("custom metric", metricSpecNames(manifest.CustomMetrics)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateNamedSpecs("background task", taskSpecNames(manifest.BackgroundTasks)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateNamedSpecs("external dependency", externalSpecNames(manifest.ExternalDeps)); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateNamedSpecs(kind string, names []string) error {
|
||||
seen := make(map[string]bool, len(names))
|
||||
for _, name := range names {
|
||||
if !schemaKeyPattern.MatchString(name) {
|
||||
return fmt.Errorf("invalid %s name %q", kind, name)
|
||||
}
|
||||
if seen[name] {
|
||||
return fmt.Errorf("duplicate %s name %q", kind, name)
|
||||
}
|
||||
seen[name] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func eventSpecNames(specs []EventSpec) []string {
|
||||
names := make([]string, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
names = append(names, spec.Name)
|
||||
for _, field := range spec.Fields {
|
||||
if !schemaKeyPattern.MatchString(field) {
|
||||
names = append(names, "invalid field "+field)
|
||||
}
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func metricSpecNames(specs []MetricSpec) []string {
|
||||
names := make([]string, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
names = append(names, spec.Name)
|
||||
for _, label := range spec.Labels {
|
||||
if !schemaKeyPattern.MatchString(label) {
|
||||
names = append(names, "invalid label "+label)
|
||||
}
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func taskSpecNames(specs []TaskSpec) []string {
|
||||
names := make([]string, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
names = append(names, spec.ID)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func externalSpecNames(specs []ExternalSpec) []string {
|
||||
names := make([]string, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
names = append(names, spec.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func validateSourceEntries(manifest Manifest, entries map[string]*zip.File) error {
|
||||
if _, ok := entries["go.mod"]; !ok {
|
||||
return errors.New("source package requires go.mod")
|
||||
}
|
||||
buildEntry := sourceBuildEntry(manifest)
|
||||
if buildEntry == "" || buildEntry == "." {
|
||||
buildEntry = "."
|
||||
}
|
||||
cleanBuildEntry, err := cleanZipName(buildEntry)
|
||||
if buildEntry == "." {
|
||||
cleanBuildEntry = "."
|
||||
err = nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid runtime.build_entry: %w", err)
|
||||
}
|
||||
hasBuildSource := false
|
||||
hasAnySource := false
|
||||
for name := range entries {
|
||||
switch {
|
||||
case name == "manifest.json" || name == "go.mod" || name == "go.sum":
|
||||
case strings.HasPrefix(name, "vendor/"):
|
||||
case strings.EqualFold(path.Base(name), "README.md"), strings.EqualFold(path.Base(name), "LICENSE"), strings.Contains(strings.ToLower(path.Base(name)), "sbom"):
|
||||
case strings.HasSuffix(name, ".go"):
|
||||
default:
|
||||
return fmt.Errorf("unsupported source package entry %q", name)
|
||||
}
|
||||
if strings.HasSuffix(name, ".go") {
|
||||
hasAnySource = true
|
||||
if cleanBuildEntry == "." || strings.HasPrefix(name, cleanBuildEntry+"/") || path.Dir(name) == cleanBuildEntry {
|
||||
hasBuildSource = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasAnySource {
|
||||
return errors.New("source package requires at least one Go source file")
|
||||
}
|
||||
if !hasBuildSource {
|
||||
return fmt.Errorf("source package build entry %q has no Go source files", manifest.Runtime.BuildEntry)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sourceBuildEntry(manifest Manifest) string {
|
||||
entry := rawSourceBuildEntry(manifest)
|
||||
if entry == "" {
|
||||
return SourceBuildEntry
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func rawSourceBuildEntry(manifest Manifest) string {
|
||||
entry := strings.Trim(strings.TrimSpace(manifest.Build.Entry), "/")
|
||||
if entry == "" {
|
||||
entry = strings.Trim(strings.TrimSpace(manifest.Runtime.BuildEntry), "/")
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func extensionPointKeys(manifest Manifest) []string {
|
||||
keys := make([]string, 0, len(manifest.ExtensionPoints))
|
||||
for _, ep := range manifest.ExtensionPoints {
|
||||
keys = append(keys, ep.Key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func cleanZipName(name string) (string, error) {
|
||||
if name == "" || strings.Contains(name, `\`) || strings.HasPrefix(name, "/") {
|
||||
return "", fmt.Errorf("unsafe zip entry %q", name)
|
||||
}
|
||||
clean := path.Clean(name)
|
||||
if clean == "." || clean != name || strings.HasPrefix(clean, "../") || clean == ".." || path.IsAbs(clean) {
|
||||
return "", fmt.Errorf("unsafe zip entry %q", name)
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
func cleanZipDirName(name string) (string, error) {
|
||||
name = strings.TrimSuffix(name, "/")
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("unsafe zip entry %q", name)
|
||||
}
|
||||
return cleanZipName(name)
|
||||
}
|
||||
|
||||
func readZipFile(file *zip.File, maxBytes int64) ([]byte, error) {
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
var buf bytes.Buffer
|
||||
if _, err := io.CopyN(&buf, rc, maxBytes+1); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, err
|
||||
}
|
||||
if int64(buf.Len()) > maxBytes {
|
||||
return nil, fmt.Errorf("zip entry %q exceeds limit %d", file.Name, maxBytes)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func fileSHA256(path string) (string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(hash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
input, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer input.Close()
|
||||
|
||||
output, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer output.Close()
|
||||
if _, err := io.Copy(output, input); err != nil {
|
||||
return err
|
||||
}
|
||||
return output.Close()
|
||||
}
|
||||
234
internal/pluginmanager/artifact_test.go
Normal file
234
internal/pluginmanager/artifact_test.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package pluginmanager
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestArtifactStoreValidateAndStore(t *testing.T) {
|
||||
packagePath := writeTestMCGP(t, map[string][]byte{
|
||||
"manifest.json": testManifestBytes(t, "test-plugin"),
|
||||
"plugin.so": []byte("fake plugin bytes"),
|
||||
})
|
||||
store := NewArtifactStore(t.TempDir())
|
||||
artifact, err := store.ValidateAndStore(ArtifactUpload{
|
||||
SourcePath: packagePath,
|
||||
FileName: "test-plugin.mcgp",
|
||||
Actor: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateAndStore() error = %v", err)
|
||||
}
|
||||
if artifact.PluginID != "test-plugin" || artifact.Status != ArtifactStatusLoadable {
|
||||
t.Fatalf("artifact = %+v, want loadable test-plugin", artifact)
|
||||
}
|
||||
if artifact.SHA256 == "" || artifact.PackageSHA256 == "" {
|
||||
t.Fatalf("artifact hashes not set: %+v", artifact)
|
||||
}
|
||||
if _, err := os.Stat(artifact.FilePath); err != nil {
|
||||
t.Fatalf("stored runtime entry stat error = %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(artifact.FilePath, filepath.Join("test-plugin", artifact.ID, "plugin.so")) {
|
||||
t.Fatalf("artifact file path = %q", artifact.FilePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactStoreValidateAndStoreSource(t *testing.T) {
|
||||
packagePath := writeTestMCGP(t, map[string][]byte{
|
||||
"manifest.json": testSourceManifestBytes(t, "source-plugin"),
|
||||
"go.mod": []byte("module example.com/source-plugin\n\ngo 1.24.0\n"),
|
||||
"main.go": []byte("package main\n"),
|
||||
"README.md": []byte("source fixture"),
|
||||
})
|
||||
store := NewArtifactStore(t.TempDir())
|
||||
source, err := store.ValidateAndStoreSource(ArtifactUpload{
|
||||
SourcePath: packagePath,
|
||||
FileName: "source-plugin.mcgp",
|
||||
Actor: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateAndStoreSource() error = %v", err)
|
||||
}
|
||||
if source.ArtifactType != ArtifactTypeSource || source.Status != ArtifactStatusValidated {
|
||||
t.Fatalf("source = %+v, want validated source", source)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(source.FilePath, "go.mod")); err != nil {
|
||||
t.Fatalf("stored source go.mod stat error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactStoreRejectsUnsafePackage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
entries map[string][]byte
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "zip slip",
|
||||
entries: map[string][]byte{
|
||||
"manifest.json": testManifestBytes(t, "test-plugin"),
|
||||
"../plugin.so": []byte("fake"),
|
||||
},
|
||||
want: "unsafe zip entry",
|
||||
},
|
||||
{
|
||||
name: "normalized escape",
|
||||
entries: map[string][]byte{
|
||||
"manifest.json": testManifestBytes(t, "test-plugin"),
|
||||
"nested/../plugin.so": []byte("fake"),
|
||||
},
|
||||
want: "unsafe zip entry",
|
||||
},
|
||||
{
|
||||
name: "missing manifest",
|
||||
entries: map[string][]byte{
|
||||
"plugin.so": []byte("fake"),
|
||||
},
|
||||
want: "manifest.json is required",
|
||||
},
|
||||
{
|
||||
name: "missing runtime",
|
||||
entries: map[string][]byte{
|
||||
"manifest.json": testManifestBytes(t, "test-plugin"),
|
||||
},
|
||||
want: `runtime entry "plugin.so" is required`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
store := NewArtifactStore(t.TempDir())
|
||||
_, err := store.ValidateAndStore(ArtifactUpload{
|
||||
SourcePath: writeTestMCGP(t, tt.entries),
|
||||
FileName: "bad.mcgp",
|
||||
Actor: "admin",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("ValidateAndStore() error = %v, want containing %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactStoreRejectsSourceShellScripts(t *testing.T) {
|
||||
store := NewArtifactStore(t.TempDir())
|
||||
_, err := store.ValidateAndStoreSource(ArtifactUpload{
|
||||
SourcePath: writeTestMCGP(t, map[string][]byte{
|
||||
"manifest.json": testSourceManifestBytes(t, "test-plugin"),
|
||||
"go.mod": []byte("module example.com/test\n"),
|
||||
"main.go": []byte("package main\n"),
|
||||
"build.sh": []byte("go build"),
|
||||
}),
|
||||
FileName: "bad-source.mcgp",
|
||||
Actor: "admin",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported source package entry") {
|
||||
t.Fatalf("ValidateAndStoreSource() error = %v, want unsupported source entry", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testSourceManifestBytes(t *testing.T, pluginID string) []byte {
|
||||
t.Helper()
|
||||
manifest := Manifest{
|
||||
SchemaVersion: SchemaVersion,
|
||||
ID: pluginID,
|
||||
Name: "Source Plugin",
|
||||
Version: "0.1.0",
|
||||
ArtifactType: ArtifactTypeSource,
|
||||
Runtime: RuntimeManifest{
|
||||
Type: RuntimeGoPlugin,
|
||||
EntrySymbol: "Plugin",
|
||||
},
|
||||
Build: BuildManifest{
|
||||
Type: BuildTypeGo,
|
||||
Entry: ".",
|
||||
GoVersion: runtime.Version(),
|
||||
Tags: []string{},
|
||||
VendorRequired: false,
|
||||
Output: RuntimeEntry,
|
||||
},
|
||||
APIVersion: APIVersion,
|
||||
GoVersion: runtime.Version(),
|
||||
GOOS: runtime.GOOS,
|
||||
GOARCH: runtime.GOARCH,
|
||||
ExtensionPoints: []ExtensionPoint{{
|
||||
Type: "hook",
|
||||
Key: ExtensionUpstreamConnect,
|
||||
}},
|
||||
Capabilities: json.RawMessage(`{"extension_points":["upstream.connect/v1"]}`),
|
||||
}
|
||||
data, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal source manifest error = %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func testManifestBytes(t *testing.T, pluginID string) []byte {
|
||||
return testManifestBytesWithCapabilities(t, pluginID, json.RawMessage(`{"extension_points":["upstream.connect/v1"]}`))
|
||||
}
|
||||
|
||||
func testManifestBytesWithCapabilities(t *testing.T, pluginID string, capabilities json.RawMessage) []byte {
|
||||
t.Helper()
|
||||
if len(capabilities) == 0 {
|
||||
capabilities = json.RawMessage(`{"extension_points":["upstream.connect/v1"]}`)
|
||||
}
|
||||
manifest := Manifest{
|
||||
SchemaVersion: SchemaVersion,
|
||||
ID: pluginID,
|
||||
Name: "Test Plugin",
|
||||
Version: "0.1.0",
|
||||
ArtifactType: ArtifactTypeBinary,
|
||||
Runtime: RuntimeManifest{
|
||||
Type: RuntimeGoPlugin,
|
||||
Entry: RuntimeEntry,
|
||||
EntrySymbol: "Plugin",
|
||||
},
|
||||
APIVersion: APIVersion,
|
||||
GoVersion: runtime.Version(),
|
||||
GOOS: runtime.GOOS,
|
||||
GOARCH: runtime.GOARCH,
|
||||
ExtensionPoints: []ExtensionPoint{{
|
||||
Type: "hook",
|
||||
Key: ExtensionUpstreamConnect,
|
||||
}},
|
||||
Capabilities: capabilities,
|
||||
ConfigSchema: json.RawMessage(`{"type":"object"}`),
|
||||
RuntimeLimits: RuntimeLimits{HandlerTimeoutMS: 3000},
|
||||
}
|
||||
data, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal manifest error = %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func writeTestMCGP(t *testing.T, entries map[string][]byte) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "plugin.mcgp")
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Create package error = %v", err)
|
||||
}
|
||||
zipWriter := zip.NewWriter(file)
|
||||
for name, data := range entries {
|
||||
writer, err := zipWriter.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("Create zip entry error = %v", err)
|
||||
}
|
||||
if _, err := writer.Write(data); err != nil {
|
||||
t.Fatalf("Write zip entry error = %v", err)
|
||||
}
|
||||
}
|
||||
if err := zipWriter.Close(); err != nil {
|
||||
t.Fatalf("Close zip error = %v", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatalf("Close package error = %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
336
internal/pluginmanager/builder.go
Normal file
336
internal/pluginmanager/builder.go
Normal file
@@ -0,0 +1,336 @@
|
||||
package pluginmanager
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SourceBuilder interface {
|
||||
Build(ctx context.Context, source ArtifactRecord, req BuildRequest, build BuildRecord) (BuildResult, error)
|
||||
}
|
||||
|
||||
type BuildResult struct {
|
||||
Manifest Manifest
|
||||
ArtifactBytes []byte
|
||||
ArtifactSHA256 string
|
||||
GoVersion string
|
||||
ModuleSummary string
|
||||
GoVersionM string
|
||||
ABIFingerprint string
|
||||
LogSummary string
|
||||
Metadata map[string]any
|
||||
}
|
||||
|
||||
type LocalProcessBuilder struct {
|
||||
StoreRoot string
|
||||
}
|
||||
|
||||
func (b LocalProcessBuilder) Build(ctx context.Context, source ArtifactRecord, req BuildRequest, build BuildRecord) (BuildResult, error) {
|
||||
if source.ArtifactType != ArtifactTypeSource {
|
||||
return BuildResult{}, fmt.Errorf("artifact %s is %q, want source", source.ID, source.ArtifactType)
|
||||
}
|
||||
var manifest Manifest
|
||||
if err := json.Unmarshal([]byte(source.MetadataJSON), &manifest); err != nil {
|
||||
return BuildResult{}, fmt.Errorf("decode source manifest: %w", err)
|
||||
}
|
||||
if err := validateManifest(manifest); err != nil {
|
||||
return BuildResult{}, err
|
||||
}
|
||||
if manifest.Build.Type != "" && manifest.Build.Type != BuildTypeGo {
|
||||
return BuildResult{}, fmt.Errorf("unsupported build.type %q", manifest.Build.Type)
|
||||
}
|
||||
if manifest.Build.Output != "" && manifest.Build.Output != RuntimeEntry {
|
||||
return BuildResult{}, fmt.Errorf("unsupported build.output %q", manifest.Build.Output)
|
||||
}
|
||||
if req.VendorRequired {
|
||||
if _, err := os.Stat(filepath.Join(source.FilePath, "vendor")); err != nil {
|
||||
return BuildResult{}, errors.New("vendor is required but source package has no vendor directory")
|
||||
}
|
||||
}
|
||||
goVersion, goVersionOutput, err := commandOutput(ctx, source.FilePath, nil, "go", "version")
|
||||
if err != nil {
|
||||
return BuildResult{LogSummary: sanitizeLog(goVersionOutput)}, err
|
||||
}
|
||||
if manifest.GoVersion != "" && manifest.GoVersion != goVersion {
|
||||
return BuildResult{GoVersion: goVersion, LogSummary: sanitizeLog(goVersionOutput)}, fmt.Errorf("source go_version %q does not match builder %q", manifest.GoVersion, goVersion)
|
||||
}
|
||||
manifest.GoVersion = goVersion
|
||||
manifest.GOOS = req.GOOS
|
||||
manifest.GOARCH = req.GOARCH
|
||||
manifest.ArtifactType = ArtifactTypeBinary
|
||||
manifest.Runtime.Entry = RuntimeEntry
|
||||
|
||||
outDir, err := os.MkdirTemp("", "mc-gateway-plugin-build-*")
|
||||
if err != nil {
|
||||
return BuildResult{}, err
|
||||
}
|
||||
defer os.RemoveAll(outDir)
|
||||
outPath := filepath.Join(outDir, RuntimeEntry)
|
||||
env := buildEnvironment(req)
|
||||
args := []string{"build", "-buildmode=plugin", "-trimpath", "-buildvcs=false", "-o", outPath}
|
||||
if req.BuildTags != "" {
|
||||
args = append(args, "-tags", req.BuildTags)
|
||||
}
|
||||
buildEntry := sourceBuildEntry(manifest)
|
||||
args = append(args, buildEntry)
|
||||
_, buildLog, buildErr := commandOutput(ctx, source.FilePath, env, "go", args...)
|
||||
if buildErr != nil {
|
||||
return BuildResult{GoVersion: goVersion, LogSummary: sanitizeLog(buildLog)}, buildErr
|
||||
}
|
||||
if _, nmLog, err := commandOutput(ctx, source.FilePath, env, "go", "tool", "nm", outPath); err != nil {
|
||||
return BuildResult{GoVersion: goVersion, LogSummary: sanitizeLog(buildLog + "\n" + nmLog)}, fmt.Errorf("inspect built plugin symbols: %w", err)
|
||||
} else if err := validateBuiltSymbols(manifest, nmLog); err != nil {
|
||||
return BuildResult{GoVersion: goVersion, LogSummary: sanitizeLog(buildLog + "\n" + nmLog)}, err
|
||||
}
|
||||
artifactBytes, err := os.ReadFile(outPath)
|
||||
if err != nil {
|
||||
return BuildResult{}, err
|
||||
}
|
||||
artifactSum := sha256.Sum256(artifactBytes)
|
||||
artifactSHA := hex.EncodeToString(artifactSum[:])
|
||||
moduleSummary := "[]"
|
||||
if _, moduleLog, err := commandOutput(ctx, source.FilePath, env, "go", "list", "-m", "-json", "all"); err == nil {
|
||||
moduleSummary = summarizeGoModules(moduleLog)
|
||||
} else {
|
||||
buildLog += "\n" + moduleLog
|
||||
}
|
||||
goVersionM := "{}"
|
||||
if _, versionMLog, err := commandOutput(ctx, source.FilePath, env, "go", "version", "-m", outPath); err == nil {
|
||||
goVersionM = summarizeGoVersionM(versionMLog)
|
||||
} else {
|
||||
buildLog += "\n" + versionMLog
|
||||
}
|
||||
abi := abiFingerprint(manifest, goVersion)
|
||||
metadata := map[string]any{
|
||||
"source_sha256": source.SHA256,
|
||||
"artifact_sha256": artifactSHA,
|
||||
"builder_type": BuilderTypeLocalProcess,
|
||||
"builder_version": build.BuilderVersion,
|
||||
"go_version": goVersion,
|
||||
"go_os": req.GOOS,
|
||||
"go_arch": req.GOARCH,
|
||||
"go_amd64": req.GOAMD64,
|
||||
"go_arm64": req.GOARM64,
|
||||
"cgo_enabled": req.CGOEnabled,
|
||||
"build_tags": req.BuildTags,
|
||||
"vendor_required": req.VendorRequired,
|
||||
"abi_fingerprint": abi,
|
||||
}
|
||||
return BuildResult{
|
||||
Manifest: manifest,
|
||||
ArtifactBytes: artifactBytes,
|
||||
ArtifactSHA256: artifactSHA,
|
||||
GoVersion: goVersion,
|
||||
ModuleSummary: moduleSummary,
|
||||
GoVersionM: goVersionM,
|
||||
ABIFingerprint: abi,
|
||||
LogSummary: sanitizeLog(buildLog),
|
||||
Metadata: metadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type ContainerBuilder struct{}
|
||||
|
||||
func (ContainerBuilder) Build(context.Context, ArtifactRecord, BuildRequest, BuildRecord) (BuildResult, error) {
|
||||
return BuildResult{}, errors.New("container builder is not configured in this phase")
|
||||
}
|
||||
|
||||
func buildEnvironment(req BuildRequest) []string {
|
||||
env := os.Environ()
|
||||
env = append(env,
|
||||
"GOOS="+req.GOOS,
|
||||
"GOARCH="+req.GOARCH,
|
||||
"CGO_ENABLED="+req.CGOEnabled,
|
||||
)
|
||||
if req.GOAMD64 != "" {
|
||||
env = append(env, "GOAMD64="+req.GOAMD64)
|
||||
}
|
||||
if req.GOARM64 != "" {
|
||||
env = append(env, "GOARM64="+req.GOARM64)
|
||||
}
|
||||
if req.GOPROXY != "" {
|
||||
env = append(env, "GOPROXY="+req.GOPROXY)
|
||||
}
|
||||
if req.GONOSUMDB != "" {
|
||||
env = append(env, "GONOSUMDB="+req.GONOSUMDB)
|
||||
}
|
||||
if req.GOPRIVATE != "" {
|
||||
env = append(env, "GOPRIVATE="+req.GOPRIVATE)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func commandOutput(ctx context.Context, dir string, env []string, name string, args ...string) (string, string, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Dir = dir
|
||||
if env != nil {
|
||||
cmd.Env = env
|
||||
}
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &out
|
||||
err := cmd.Run()
|
||||
output := out.String()
|
||||
if name == "go" && len(args) == 1 && args[0] == "version" && err == nil {
|
||||
fields := strings.Fields(output)
|
||||
if len(fields) >= 3 {
|
||||
return fields[2], output, nil
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(output), output, err
|
||||
}
|
||||
|
||||
func sanitizeLog(log string) string {
|
||||
replacers := []string{
|
||||
os.Getenv("HOME"), "$HOME",
|
||||
os.TempDir(), "$TMPDIR",
|
||||
}
|
||||
sanitized := log
|
||||
for i := 0; i+1 < len(replacers); i += 2 {
|
||||
if replacers[i] != "" {
|
||||
sanitized = strings.ReplaceAll(sanitized, replacers[i], replacers[i+1])
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"TOKEN", "SECRET", "PASSWORD", "PRIVATE"} {
|
||||
for _, part := range strings.Fields(sanitized) {
|
||||
if strings.Contains(strings.ToUpper(part), key+"=") {
|
||||
sanitized = strings.ReplaceAll(sanitized, part, key+"=<redacted>")
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(sanitized) > DefaultBuildLogMaxBytes {
|
||||
sanitized = sanitized[len(sanitized)-DefaultBuildLogMaxBytes:]
|
||||
}
|
||||
lines := strings.Split(sanitized, "\n")
|
||||
if len(lines) > 80 {
|
||||
lines = lines[len(lines)-80:]
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func summarizeGoModules(raw string) string {
|
||||
decoder := json.NewDecoder(strings.NewReader(raw))
|
||||
var modules []map[string]any
|
||||
for decoder.More() {
|
||||
var module struct {
|
||||
Path string `json:"Path"`
|
||||
Version string `json:"Version"`
|
||||
Main bool `json:"Main"`
|
||||
Replace *struct {
|
||||
Path string `json:"Path"`
|
||||
Version string `json:"Version"`
|
||||
} `json:"Replace"`
|
||||
}
|
||||
if err := decoder.Decode(&module); err != nil {
|
||||
break
|
||||
}
|
||||
item := map[string]any{"path": module.Path, "version": module.Version, "main": module.Main}
|
||||
if module.Replace != nil {
|
||||
item["replace"] = map[string]string{"path": module.Replace.Path, "version": module.Replace.Version}
|
||||
}
|
||||
modules = append(modules, item)
|
||||
}
|
||||
data, err := json.Marshal(modules)
|
||||
if err != nil || len(data) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func summarizeGoVersionM(raw string) string {
|
||||
summary := map[string]any{"raw_summary": sanitizeLog(raw)}
|
||||
data, err := json.Marshal(summary)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func abiFingerprint(manifest Manifest, goVersion string) string {
|
||||
payload := strings.Join([]string{
|
||||
manifest.ID,
|
||||
manifest.APIVersion,
|
||||
manifest.SDKModule,
|
||||
manifest.SDKModuleVersion,
|
||||
goVersion,
|
||||
runtime.GOOS,
|
||||
runtime.GOARCH,
|
||||
extensionPointsFingerprint(manifest),
|
||||
}, "\x00")
|
||||
sum := sha256.Sum256([]byte(payload))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func extensionPointsFingerprint(manifest Manifest) string {
|
||||
keys := extensionPointKeys(manifest)
|
||||
data, _ := json.Marshal(keys)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func validateBuiltSymbols(manifest Manifest, nmLog string) error {
|
||||
entrySymbol := manifest.Runtime.EntrySymbol
|
||||
if entrySymbol == "" {
|
||||
entrySymbol = "Plugin"
|
||||
}
|
||||
metadataSymbol := manifest.Runtime.MetadataSymbol
|
||||
if metadataSymbol == "" {
|
||||
metadataSymbol = "MCGatewayPluginMetadata"
|
||||
}
|
||||
if !strings.Contains(nmLog, entrySymbol) {
|
||||
return fmt.Errorf("built plugin is missing entry symbol %q", entrySymbol)
|
||||
}
|
||||
if !strings.Contains(nmLog, metadataSymbol) {
|
||||
return fmt.Errorf("built plugin is missing metadata symbol %q", metadataSymbol)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultBuildRequest(req BuildRequest, source ArtifactRecord) BuildRequest {
|
||||
var manifest Manifest
|
||||
_ = json.Unmarshal([]byte(source.MetadataJSON), &manifest)
|
||||
if req.BuilderType == "" {
|
||||
req.BuilderType = BuilderTypeLocalProcess
|
||||
}
|
||||
if req.BuilderVersion == "" {
|
||||
req.BuilderVersion = "local-process/go-buildmode-plugin"
|
||||
}
|
||||
if req.GOOS == "" {
|
||||
req.GOOS = runtime.GOOS
|
||||
}
|
||||
if req.GOARCH == "" {
|
||||
req.GOARCH = runtime.GOARCH
|
||||
}
|
||||
if req.CGOEnabled == "" {
|
||||
if manifest.Build.CGOEnabled != nil && !*manifest.Build.CGOEnabled {
|
||||
req.CGOEnabled = "0"
|
||||
} else {
|
||||
req.CGOEnabled = "1"
|
||||
}
|
||||
}
|
||||
if req.BuildTags == "" && len(manifest.Build.Tags) > 0 {
|
||||
req.BuildTags = strings.Join(manifest.Build.Tags, ",")
|
||||
}
|
||||
if manifest.Build.VendorRequired {
|
||||
req.VendorRequired = true
|
||||
}
|
||||
if req.SDKModule == "" {
|
||||
req.SDKModule = manifest.SDKModule
|
||||
req.SDKVersion = manifest.SDKModuleVersion
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func buildDurationMS(start time.Time) int64 {
|
||||
return time.Since(start).Milliseconds()
|
||||
}
|
||||
129
internal/pluginmanager/gc.go
Normal file
129
internal/pluginmanager/gc.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package pluginmanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func (m *Manager) GCCandidates(ctx context.Context) ([]GCCandidate, error) {
|
||||
refs, err := m.repo.ReferencedArtifactIDs(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
artifacts, err := m.repo.ListArtifacts(ctx, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var candidates []GCCandidate
|
||||
for _, artifact := range artifacts {
|
||||
referenced := refs[artifact.ID]
|
||||
artifactPath := artifactGCPath(artifact)
|
||||
size := artifact.SizeBytes
|
||||
if info, err := os.Stat(artifactPath); err == nil && info.IsDir() {
|
||||
size = dirSize(artifactPath)
|
||||
} else if err == nil {
|
||||
size = info.Size()
|
||||
}
|
||||
candidate := GCCandidate{
|
||||
Kind: "artifact",
|
||||
ID: artifact.ID,
|
||||
PluginID: artifact.PluginID,
|
||||
Path: artifactPath,
|
||||
Protected: referenced,
|
||||
SizeBytes: size,
|
||||
CreatedAt: artifact.CreatedAt,
|
||||
Referenced: referenced,
|
||||
}
|
||||
switch {
|
||||
case referenced:
|
||||
candidate.Reason = "referenced by active/desired/snapshot/build"
|
||||
case artifact.ArtifactType == ArtifactTypeSource:
|
||||
candidate.Reason = "unreferenced source package"
|
||||
case artifact.Status == ArtifactStatusRejected || artifact.Status == ArtifactStatusDeleted:
|
||||
candidate.Reason = "unreferenced rejected/deleted artifact"
|
||||
default:
|
||||
candidate.Reason = "unreferenced artifact"
|
||||
}
|
||||
if !referenced && (artifact.ArtifactType == ArtifactTypeSource || artifact.Status == ArtifactStatusRejected || artifact.Status == ArtifactStatusDeleted) {
|
||||
candidates = append(candidates, candidate)
|
||||
}
|
||||
}
|
||||
builds, err := m.repo.ListBuilds(ctx, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, build := range builds {
|
||||
protectedBuild := build.Status == BuildStatusRunning || build.Status == BuildStatusQueued
|
||||
reason := "completed build log"
|
||||
if protectedBuild {
|
||||
reason = "in-flight build"
|
||||
}
|
||||
candidates = append(candidates, GCCandidate{
|
||||
Kind: "build_log",
|
||||
ID: buildIDString(build.ID),
|
||||
PluginID: build.PluginID,
|
||||
Protected: protectedBuild,
|
||||
Reason: reason,
|
||||
SizeBytes: int64(len(build.LogSummary)),
|
||||
CreatedAt: build.CreatedAt,
|
||||
})
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func (m *Manager) RunGC(ctx context.Context, actor string, dryRun bool) ([]GCCandidate, error) {
|
||||
candidates, err := m.GCCandidates(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dryRun {
|
||||
_ = m.repo.RecordOperation(ctx, "", "", "artifact_gc", "dry_run", actor, "artifact gc dry-run completed", map[string]any{
|
||||
"candidates": len(candidates),
|
||||
})
|
||||
return candidates, nil
|
||||
}
|
||||
var removed []GCCandidate
|
||||
for _, candidate := range candidates {
|
||||
if candidate.Protected || candidate.Path == "" || candidate.Kind != "artifact" {
|
||||
continue
|
||||
}
|
||||
if err := os.RemoveAll(candidate.Path); err != nil {
|
||||
_ = m.repo.RecordOperation(ctx, candidate.PluginID, candidate.ID, "artifact_gc", "failed", actor, err.Error(), map[string]any{
|
||||
"path": candidate.Path,
|
||||
})
|
||||
continue
|
||||
}
|
||||
removed = append(removed, candidate)
|
||||
}
|
||||
_ = m.repo.RecordOperation(ctx, "", "", "artifact_gc", "succeeded", actor, "artifact gc completed", map[string]any{
|
||||
"removed": len(removed),
|
||||
})
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
func artifactGCPath(artifact ArtifactRecord) string {
|
||||
if artifact.FilePath == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Dir(artifact.FilePath)
|
||||
}
|
||||
|
||||
func dirSize(root string) int64 {
|
||||
var total int64
|
||||
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if info, err := d.Info(); err == nil {
|
||||
total += info.Size()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return total
|
||||
}
|
||||
|
||||
func buildIDString(id int64) string {
|
||||
return strconv.FormatInt(id, 10)
|
||||
}
|
||||
1284
internal/pluginmanager/governance.go
Normal file
1284
internal/pluginmanager/governance.go
Normal file
File diff suppressed because it is too large
Load Diff
174
internal/pluginmanager/governance_test.go
Normal file
174
internal/pluginmanager/governance_test.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package pluginmanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGovernanceHighRiskProtocolProxyRequiresReview(t *testing.T) {
|
||||
manager := newManagerForTest(t, &fakeAdapter{})
|
||||
artifact := uploadTestArtifactWithCapabilities(t, manager, "proxy-review", testProtocolProxyCapabilities())
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "proxy-review", artifact.ID, DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
_, err := manager.Enable(context.Background(), "admin", "proxy-review")
|
||||
if err == nil || !strings.Contains(err.Error(), "review_required") {
|
||||
t.Fatalf("Enable() error = %v, want review_required", err)
|
||||
}
|
||||
if _, err := manager.CreateReview(context.Background(), "admin", "proxy-review", GovernanceReviewRequest{
|
||||
ArtifactID: artifact.ID,
|
||||
Profile: PolicyProfileProd,
|
||||
Decision: ReviewDecisionApproved,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateReview() error = %v", err)
|
||||
}
|
||||
if _, err := manager.Enable(context.Background(), "admin", "proxy-review"); err != nil {
|
||||
t.Fatalf("Enable(after review) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGovernanceBlocksProtocolProxyScopeOverlap(t *testing.T) {
|
||||
manager := newManagerForTest(t, &fakeAdapter{})
|
||||
first := enableProtocolProxyTestPlugin(t, manager, "proxy-a")
|
||||
if first.ID == "" {
|
||||
t.Fatal("first protocol proxy artifact id is empty")
|
||||
}
|
||||
second := uploadTestArtifactWithCapabilities(t, manager, "proxy-b", testProtocolProxyCapabilities())
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "proxy-b", second.ID, DesiredEnabled, `{}`, 20); err != nil {
|
||||
t.Fatalf("SetDesired(second) error = %v", err)
|
||||
}
|
||||
approveGovernanceForTest(t, manager, "proxy-b", second.ID)
|
||||
_, err := manager.Enable(context.Background(), "admin", "proxy-b")
|
||||
if err == nil || !strings.Contains(err.Error(), "scope_overlap") {
|
||||
t.Fatalf("Enable(second) error = %v, want scope_overlap", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGovernanceBlocksMissingFeatureAndSecret(t *testing.T) {
|
||||
manager := newManagerForTest(t, &fakeAdapter{})
|
||||
artifact := uploadTestArtifactWithManifest(t, manager, "feature-secret", func(manifest *Manifest) {
|
||||
manifest.Capabilities = json.RawMessage(`{"required_features":["wasm-sandbox"]}`)
|
||||
manifest.Secrets = []SecretSpec{{Name: "api_token", Required: true}}
|
||||
})
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "feature-secret", artifact.ID, DesiredEnabled, `{}`, 10); err == nil {
|
||||
t.Fatal("SetDesired() error = nil, want missing secret from dry-run")
|
||||
}
|
||||
if _, err := manager.UpsertSecret(context.Background(), "admin", "feature-secret", artifact.ID, "api_token", "secret", true, false); err != nil {
|
||||
t.Fatalf("UpsertSecret() error = %v", err)
|
||||
}
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "feature-secret", artifact.ID, DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired(after secret) error = %v", err)
|
||||
}
|
||||
_, err := manager.Enable(context.Background(), "admin", "feature-secret")
|
||||
if err == nil || !strings.Contains(err.Error(), "feature_missing") {
|
||||
t.Fatalf("Enable() error = %v, want feature_missing", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGovernanceAdvisoryRevokeBlocksRollback(t *testing.T) {
|
||||
manager := newManagerForTest(t, &fakeAdapter{})
|
||||
oldArtifact := uploadTestArtifact(t, manager, "revoke-plugin")
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "revoke-plugin", oldArtifact.ID, DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired(old) error = %v", err)
|
||||
}
|
||||
if _, err := manager.Enable(context.Background(), "admin", "revoke-plugin"); err != nil {
|
||||
t.Fatalf("Enable(old) error = %v", err)
|
||||
}
|
||||
newArtifact := uploadTestArtifactWithManifest(t, manager, "revoke-plugin", func(manifest *Manifest) {
|
||||
manifest.Version = "0.2.0"
|
||||
})
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "revoke-plugin", newArtifact.ID, DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired(new) error = %v", err)
|
||||
}
|
||||
if _, err := manager.Enable(context.Background(), "admin", "revoke-plugin"); err != nil {
|
||||
t.Fatalf("Enable(new) error = %v", err)
|
||||
}
|
||||
if _, err := manager.UpsertAdvisory(context.Background(), "admin", AdvisoryRequest{
|
||||
AdvisoryID: "MCG-2026-0001",
|
||||
Status: AdvisoryStatusRevoked,
|
||||
Action: AdvisoryActionRevoke,
|
||||
ArtifactSHA256: oldArtifact.SHA256,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertAdvisory() error = %v", err)
|
||||
}
|
||||
_, err := manager.RollbackArtifact(context.Background(), "admin", "revoke-plugin", oldArtifact.ID)
|
||||
if err == nil || !strings.Contains(err.Error(), "advisory_revoke") {
|
||||
t.Fatalf("RollbackArtifact() error = %v, want advisory_revoke", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGovernanceWarningOverrideTTL(t *testing.T) {
|
||||
now := time.Unix(1000, 0)
|
||||
db := openPluginManagerTestDB(t)
|
||||
manager := New(Options{
|
||||
DB: db,
|
||||
ArtifactRoot: t.TempDir(),
|
||||
Adapter: &fakeAdapter{},
|
||||
})
|
||||
manager.repo.now = func() time.Time { return now }
|
||||
artifact := uploadTestArtifact(t, manager, "bench-plugin")
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "bench-plugin", artifact.ID, DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
if _, err := manager.SaveBenchmark(context.Background(), "admin", BenchmarkRequest{
|
||||
ArtifactID: artifact.ID,
|
||||
Profile: PolicyProfileProd,
|
||||
BenchmarkProfile: "release",
|
||||
P95MS: 10,
|
||||
P99MS: 20,
|
||||
BaselineDiff: 0.30,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveBenchmark() error = %v", err)
|
||||
}
|
||||
_, err := manager.Enable(context.Background(), "admin", "bench-plugin")
|
||||
if err == nil || !strings.Contains(err.Error(), "benchmark_regression_warning") {
|
||||
t.Fatalf("Enable() error = %v, want benchmark warning", err)
|
||||
}
|
||||
if _, err := manager.CreateWarningOverride(context.Background(), "admin", "bench-plugin", WarningOverrideRequest{
|
||||
ArtifactID: artifact.ID,
|
||||
Profile: PolicyProfileProd,
|
||||
Action: GovernanceActionEnable,
|
||||
Reason: "accepted for canary",
|
||||
TTLSeconds: 60,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateWarningOverride() error = %v", err)
|
||||
}
|
||||
if _, err := manager.Enable(context.Background(), "admin", "bench-plugin"); err != nil {
|
||||
t.Fatalf("Enable(with override) error = %v", err)
|
||||
}
|
||||
if _, err := manager.Disable(context.Background(), "admin", "bench-plugin"); err != nil {
|
||||
t.Fatalf("Disable() error = %v", err)
|
||||
}
|
||||
now = now.Add(2 * time.Minute)
|
||||
_, err = manager.Enable(context.Background(), "admin", "bench-plugin")
|
||||
if err == nil || !strings.Contains(err.Error(), "benchmark_regression_warning") {
|
||||
t.Fatalf("Enable(after override expiry) error = %v, want benchmark warning", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGovernanceBenchmarkBlocking(t *testing.T) {
|
||||
manager := newManagerForTest(t, &fakeAdapter{})
|
||||
artifact := uploadTestArtifactWithManifest(t, manager, "bench-block", func(manifest *Manifest) {
|
||||
manifest.RuntimeLimits.HandlerTimeoutMS = 100
|
||||
})
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "bench-block", artifact.ID, DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
if _, err := manager.SaveBenchmark(context.Background(), "admin", BenchmarkRequest{
|
||||
ArtifactID: artifact.ID,
|
||||
Profile: PolicyProfileProd,
|
||||
BenchmarkProfile: "release",
|
||||
P95MS: 80,
|
||||
P99MS: 200,
|
||||
BaselineDiff: 0.60,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveBenchmark() error = %v", err)
|
||||
}
|
||||
_, err := manager.Enable(context.Background(), "admin", "bench-block")
|
||||
if err == nil || !strings.Contains(err.Error(), "benchmark_regression_blocking") {
|
||||
t.Fatalf("Enable() error = %v, want benchmark_regression_blocking", err)
|
||||
}
|
||||
}
|
||||
2097
internal/pluginmanager/manager.go
Normal file
2097
internal/pluginmanager/manager.go
Normal file
File diff suppressed because it is too large
Load Diff
1119
internal/pluginmanager/manager_test.go
Normal file
1119
internal/pluginmanager/manager_test.go
Normal file
File diff suppressed because it is too large
Load Diff
1590
internal/pluginmanager/operations.go
Normal file
1590
internal/pluginmanager/operations.go
Normal file
File diff suppressed because it is too large
Load Diff
1472
internal/pluginmanager/repository.go
Normal file
1472
internal/pluginmanager/repository.go
Normal file
File diff suppressed because it is too large
Load Diff
948
internal/pluginmanager/types.go
Normal file
948
internal/pluginmanager/types.go
Normal file
@@ -0,0 +1,948 @@
|
||||
package pluginmanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tursom/mc-gateway/plugin/api"
|
||||
)
|
||||
|
||||
const (
|
||||
SchemaVersion = "mc-gateway.plugin/v1"
|
||||
APIVersion = "plugin-api/v1"
|
||||
|
||||
ArtifactTypeBinary = "binary"
|
||||
ArtifactTypeSource = "source"
|
||||
RuntimeGoPlugin = "go-plugin"
|
||||
RuntimeEntry = "plugin.so"
|
||||
SourceBuildEntry = "."
|
||||
|
||||
ExtensionUpstreamConnect = "upstream.connect/v1"
|
||||
|
||||
UpstreamModeDialer = "dialer"
|
||||
UpstreamModeProtocolProxy = "protocol-proxy"
|
||||
|
||||
ArtifactStatusUploaded = "uploaded"
|
||||
ArtifactStatusValidated = "validated"
|
||||
ArtifactStatusLoadable = "loadable"
|
||||
ArtifactStatusLoaded = "loaded"
|
||||
ArtifactStatusRejected = "rejected"
|
||||
ArtifactStatusDeleted = "deleted"
|
||||
|
||||
BuildStatusQueued = "queued"
|
||||
BuildStatusRunning = "running"
|
||||
BuildStatusSucceeded = "succeeded"
|
||||
BuildStatusFailed = "failed"
|
||||
BuildStatusCanceled = "canceled"
|
||||
|
||||
BuilderTypeLocalProcess = "local-process"
|
||||
BuilderTypeContainer = "container"
|
||||
BuildTypeGo = "go"
|
||||
|
||||
DesiredEnabled = "enabled"
|
||||
DesiredDisabled = "disabled"
|
||||
DesiredDeleted = "deleted"
|
||||
|
||||
RuntimeNotLoaded = "not_loaded"
|
||||
RuntimeLoaded = "loaded"
|
||||
RuntimeEnabled = "enabled"
|
||||
RuntimeFailed = "failed"
|
||||
RuntimeDisabled = "disabled"
|
||||
RuntimeDraining = "draining"
|
||||
|
||||
PolicyProfileDev = "dev"
|
||||
PolicyProfileStaging = "staging"
|
||||
PolicyProfileProd = "prod"
|
||||
|
||||
RiskLow = "low"
|
||||
RiskMedium = "medium"
|
||||
RiskHigh = "high"
|
||||
|
||||
GateSeverityWarning = "warning"
|
||||
GateSeverityBlocking = "blocking"
|
||||
GateSeverityInfo = "info"
|
||||
|
||||
GovernanceActionEnable = "enable"
|
||||
GovernanceActionRollback = "rollback"
|
||||
GovernanceActionPromotion = "promotion_apply"
|
||||
|
||||
AdvisoryActionDenylist = "denylist"
|
||||
AdvisoryActionQuarantine = "quarantine"
|
||||
AdvisoryActionRevoke = "revoke"
|
||||
AdvisoryActionMitigate = "mitigate"
|
||||
|
||||
ReviewDecisionApproved = "approved"
|
||||
ReviewDecisionRejected = "rejected"
|
||||
|
||||
AdvisoryStatusActive = "active"
|
||||
AdvisoryStatusRevoked = "revoked"
|
||||
AdvisoryStatusAcked = "acknowledged"
|
||||
|
||||
DefaultPriority = 100
|
||||
DefaultHandlerTimeout = 3 * time.Second
|
||||
DefaultManifestMaxBytes = 256 * 1024
|
||||
DefaultPackageMaxBytes = 64 * 1024 * 1024
|
||||
DefaultPackageMaxEntries = 2048
|
||||
DefaultExtractedMaxBytes = 256 * 1024 * 1024
|
||||
DefaultNonRuntimeMaxBytes = 16 * 1024 * 1024
|
||||
DefaultInitialWriteTimeout = time.Second
|
||||
DefaultExternalTimeout = 5 * time.Second
|
||||
DefaultBuildLogMaxBytes = 64 * 1024
|
||||
DefaultEventQueueLimit = 1000
|
||||
DefaultEventRecentLimit = 1000
|
||||
DefaultLabelValueMaxBytes = 64
|
||||
DefaultPluginDataQuota = 16 * 1024 * 1024
|
||||
DefaultPluginDataKeyLimit = 256 * 1024
|
||||
DefaultPluginFileQuota = 32 * 1024 * 1024
|
||||
DefaultLogRecentLimit = 500
|
||||
)
|
||||
|
||||
var (
|
||||
ErrArtifactNotFound = errors.New("plugin artifact not found")
|
||||
ErrPluginNotFound = errors.New("plugin not found")
|
||||
)
|
||||
|
||||
type Manifest struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
ArtifactType string `json:"artifact_type"`
|
||||
Runtime RuntimeManifest `json:"runtime"`
|
||||
Build BuildManifest `json:"build,omitempty"`
|
||||
APIVersion string `json:"api_version"`
|
||||
SDKModule string `json:"sdk_module"`
|
||||
SDKModuleVersion string `json:"sdk_module_version"`
|
||||
GoVersion string `json:"go_version"`
|
||||
GOOS string `json:"go_os"`
|
||||
GOARCH string `json:"go_arch"`
|
||||
ExtensionPoints []ExtensionPoint `json:"extension_points"`
|
||||
Capabilities json.RawMessage `json:"capabilities"`
|
||||
RuntimeLimits RuntimeLimits `json:"runtime_limits"`
|
||||
ConfigSchema json.RawMessage `json:"config_schema"`
|
||||
Secrets []SecretSpec `json:"secrets,omitempty"`
|
||||
Events []EventSpec `json:"events,omitempty"`
|
||||
CustomMetrics []MetricSpec `json:"custom_metrics,omitempty"`
|
||||
BackgroundTasks []TaskSpec `json:"background_tasks,omitempty"`
|
||||
ExternalDeps []ExternalSpec `json:"external_dependencies,omitempty"`
|
||||
DataStores []DataStoreSpec `json:"data_stores,omitempty"`
|
||||
FileStores []FileStoreSpec `json:"file_stores,omitempty"`
|
||||
SupplyChain json.RawMessage `json:"supply_chain"`
|
||||
}
|
||||
|
||||
type RuntimeManifest struct {
|
||||
Type string `json:"type"`
|
||||
Entry string `json:"entry"`
|
||||
BuildEntry string `json:"build_entry"`
|
||||
EntrySymbol string `json:"entry_symbol"`
|
||||
MetadataSymbol string `json:"metadata_symbol"`
|
||||
}
|
||||
|
||||
type BuildManifest struct {
|
||||
Type string `json:"type"`
|
||||
Entry string `json:"entry"`
|
||||
GoVersion string `json:"go_version"`
|
||||
CGOEnabled *bool `json:"cgo_enabled,omitempty"`
|
||||
Tags []string `json:"tags"`
|
||||
VendorRequired bool `json:"vendor_required"`
|
||||
Output string `json:"output"`
|
||||
}
|
||||
|
||||
type ExtensionPoint struct {
|
||||
Type string `json:"type"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
type RuntimeLimits struct {
|
||||
HandlerTimeoutMS int `json:"handler_timeout_ms"`
|
||||
InitialWriteTimeoutMS int `json:"initial_write_timeout_ms"`
|
||||
}
|
||||
|
||||
type SecretSpec struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Required bool `json:"required"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Rotation SecretRotation `json:"rotation,omitempty"`
|
||||
}
|
||||
|
||||
type SecretRotation struct {
|
||||
Strategy string `json:"strategy,omitempty"`
|
||||
GracePeriod string `json:"grace_period,omitempty"`
|
||||
Reload string `json:"reload,omitempty"`
|
||||
}
|
||||
|
||||
type EventSpec struct {
|
||||
Name string `json:"name"`
|
||||
Fields []string `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
type MetricSpec struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
}
|
||||
|
||||
type TaskSpec struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Interval string `json:"interval,omitempty"`
|
||||
RunOnStart bool `json:"run_on_start,omitempty"`
|
||||
Jitter string `json:"jitter,omitempty"`
|
||||
Timeout string `json:"timeout,omitempty"`
|
||||
Manual bool `json:"manual,omitempty"`
|
||||
RequireRole string `json:"require_role,omitempty"`
|
||||
}
|
||||
|
||||
type ExternalSpec struct {
|
||||
Name string `json:"name"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Purpose string `json:"purpose,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Timeout string `json:"timeout,omitempty"`
|
||||
Retry int `json:"retry,omitempty"`
|
||||
FailPolicy string `json:"fail_policy,omitempty"`
|
||||
DataClasses []string `json:"data_classes,omitempty"`
|
||||
Traceparent bool `json:"traceparent,omitempty"`
|
||||
}
|
||||
|
||||
type DataStoreSpec struct {
|
||||
Name string `json:"name"`
|
||||
SchemaVersion int `json:"schema_version,omitempty"`
|
||||
DataClass string `json:"data_class,omitempty"`
|
||||
QuotaBytes int64 `json:"quota_bytes,omitempty"`
|
||||
Retention string `json:"retention,omitempty"`
|
||||
Exportable bool `json:"exportable,omitempty"`
|
||||
}
|
||||
|
||||
type FileStoreSpec struct {
|
||||
Namespace string `json:"namespace"`
|
||||
DataClass string `json:"data_class,omitempty"`
|
||||
QuotaBytes int64 `json:"quota_bytes,omitempty"`
|
||||
Retention string `json:"retention,omitempty"`
|
||||
Readonly bool `json:"readonly,omitempty"`
|
||||
}
|
||||
|
||||
type CapabilitySummary struct {
|
||||
UpstreamConnect UpstreamConnectCapability `json:"upstream_connect,omitempty"`
|
||||
Minecraft *MinecraftCapability `json:"minecraft,omitempty"`
|
||||
Events []EventSpec `json:"events,omitempty"`
|
||||
CustomMetrics []MetricSpec `json:"custom_metrics,omitempty"`
|
||||
ExternalDeps []ExternalSpec `json:"external_dependencies,omitempty"`
|
||||
DataStores []DataStoreSpec `json:"data_stores,omitempty"`
|
||||
FileStores []FileStoreSpec `json:"file_stores,omitempty"`
|
||||
Raw json.RawMessage `json:"raw,omitempty"`
|
||||
}
|
||||
|
||||
type UpstreamConnectCapability struct {
|
||||
Mode string `json:"mode,omitempty"`
|
||||
}
|
||||
|
||||
type MinecraftCapability struct {
|
||||
ProtocolVersions MinecraftProtocolVersions `json:"protocol_versions,omitempty"`
|
||||
States map[string]string `json:"states,omitempty"`
|
||||
AuthModes []string `json:"auth_modes,omitempty"`
|
||||
Forwarding MinecraftForwarding `json:"forwarding,omitempty"`
|
||||
UnsupportedPolicy string `json:"unsupported_policy,omitempty"`
|
||||
Modded map[string]string `json:"modded,omitempty"`
|
||||
}
|
||||
|
||||
type MinecraftProtocolVersions struct {
|
||||
Min int `json:"min,omitempty"`
|
||||
Max int `json:"max,omitempty"`
|
||||
Tested []int `json:"tested,omitempty"`
|
||||
UnsupportedPolicy string `json:"unsupported_policy,omitempty"`
|
||||
}
|
||||
|
||||
type MinecraftForwarding struct {
|
||||
Supported []string `json:"supported,omitempty"`
|
||||
Default string `json:"default,omitempty"`
|
||||
RequiresSecret bool `json:"requires_secret,omitempty"`
|
||||
}
|
||||
|
||||
type ArtifactRecord struct {
|
||||
ID string `json:"id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
Version string `json:"version"`
|
||||
FileName string `json:"file_name"`
|
||||
FilePath string `json:"file_path"`
|
||||
SHA256 string `json:"sha256"`
|
||||
PackageSHA256 string `json:"package_sha256"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
ArtifactType string `json:"artifact_type"`
|
||||
RuntimeType string `json:"runtime_type"`
|
||||
RuntimeEntry string `json:"runtime_entry"`
|
||||
Status string `json:"status"`
|
||||
MetadataJSON string `json:"metadata_json"`
|
||||
CapabilitiesSummaryJSON string `json:"capabilities_summary_json"`
|
||||
ExtensionPointsJSON string `json:"extension_points_json"`
|
||||
APIVersion string `json:"api_version"`
|
||||
GoVersion string `json:"go_version"`
|
||||
GOOS string `json:"go_os"`
|
||||
GOARCH string `json:"go_arch"`
|
||||
UploadedBy string `json:"uploaded_by"`
|
||||
Error string `json:"error"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type PluginRecord struct {
|
||||
ID string `json:"id"`
|
||||
DesiredArtifactID string `json:"desired_artifact_id"`
|
||||
ActiveArtifactID string `json:"active_artifact_id"`
|
||||
LoadedArtifactID string `json:"loaded_artifact_id"`
|
||||
DesiredState string `json:"desired_state"`
|
||||
RuntimeState string `json:"runtime_state"`
|
||||
Priority int `json:"priority"`
|
||||
ConfigJSON string `json:"config_json"`
|
||||
DesiredGeneration int64 `json:"desired_generation"`
|
||||
AppliedGeneration int64 `json:"applied_generation"`
|
||||
LastError string `json:"last_error"`
|
||||
RuntimeSummaryJSON string `json:"runtime_summary_json"`
|
||||
DispatchSummaryJSON string `json:"dispatch_summary_json"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
UpdatedBy string `json:"updated_by"`
|
||||
}
|
||||
|
||||
type ConfigSnapshotRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
ConfigJSON string `json:"config_json"`
|
||||
DesiredState string `json:"desired_state"`
|
||||
Priority int `json:"priority"`
|
||||
DesiredGeneration int64 `json:"desired_generation"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type ConfigDryRunResult struct {
|
||||
OK bool `json:"ok"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
RestartRequired bool `json:"restart_required"`
|
||||
HotReload bool `json:"hot_reload"`
|
||||
SensitivePaths []string `json:"sensitive_paths"`
|
||||
RedactedConfigJSON string `json:"redacted_config_json"`
|
||||
RedactedDiffJSON string `json:"redacted_diff_json"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type ConfigSnapshotDiff struct {
|
||||
SnapshotID int64 `json:"snapshot_id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
SensitivePaths []string `json:"sensitive_paths"`
|
||||
RedactedDiffJSON string `json:"redacted_diff_json"`
|
||||
RestartRequired bool `json:"restart_required"`
|
||||
CurrentGeneration int64 `json:"current_generation"`
|
||||
SnapshotGeneration int64 `json:"snapshot_generation"`
|
||||
}
|
||||
|
||||
type SecretRecord struct {
|
||||
PluginID string `json:"plugin_id"`
|
||||
Name string `json:"name"`
|
||||
CurrentVersion int64 `json:"current_version"`
|
||||
PreviousVersion int64 `json:"previous_version"`
|
||||
ReloadRequired bool `json:"reload_required"`
|
||||
HotReload bool `json:"hot_reload"`
|
||||
UpdatedBy string `json:"updated_by"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type PolicySnapshot struct {
|
||||
Profile string `json:"profile"`
|
||||
WarningOverrideTTLSeconds int64 `json:"warning_override_ttl_seconds"`
|
||||
ReviewRequiredRisk string `json:"review_required_risk"`
|
||||
WarnBenchmarkRegression float64 `json:"warn_benchmark_regression"`
|
||||
BlockBenchmarkRegression float64 `json:"block_benchmark_regression"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type GovernanceIssue struct {
|
||||
Code string `json:"code"`
|
||||
Severity string `json:"severity"`
|
||||
Message string `json:"message"`
|
||||
PluginID string `json:"plugin_id,omitempty"`
|
||||
ArtifactID string `json:"artifact_id,omitempty"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
type GovernanceDecision struct {
|
||||
OK bool `json:"ok"`
|
||||
Action string `json:"action"`
|
||||
Profile string `json:"profile"`
|
||||
RiskLevel string `json:"risk_level"`
|
||||
PolicyHash string `json:"policy_hash"`
|
||||
ReviewRequired bool `json:"review_required"`
|
||||
WarningOverrideUsed bool `json:"warning_override_used"`
|
||||
Issues []GovernanceIssue `json:"issues"`
|
||||
Checks []GovernanceIssue `json:"checks"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type ConflictAnalysis struct {
|
||||
OK bool `json:"ok"`
|
||||
Issues []GovernanceIssue `json:"issues"`
|
||||
Plan DispatchPlan `json:"plan"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type PreflightCheck struct {
|
||||
Code string `json:"code"`
|
||||
Severity string `json:"severity"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
type PreflightResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Profile string `json:"profile"`
|
||||
Checks []PreflightCheck `json:"checks"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type GovernanceStatus struct {
|
||||
Decision GovernanceDecision `json:"decision"`
|
||||
Policy PolicySnapshot `json:"policy"`
|
||||
Reviews []ReviewRecord `json:"reviews"`
|
||||
WarningOverrides []WarningOverrideRecord `json:"warning_overrides"`
|
||||
Preflights []PreflightRecord `json:"preflights"`
|
||||
Benchmarks []BenchmarkRecord `json:"benchmarks"`
|
||||
Advisories []AdvisoryRecord `json:"advisories"`
|
||||
Conflicts ConflictAnalysis `json:"conflicts"`
|
||||
}
|
||||
|
||||
type ReviewRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Profile string `json:"profile"`
|
||||
RiskLevel string `json:"risk_level"`
|
||||
ConfigHash string `json:"config_hash"`
|
||||
ScopeHash string `json:"scope_hash"`
|
||||
RolloutHash string `json:"rollout_hash"`
|
||||
RuntimeLimitsHash string `json:"runtime_limits_hash"`
|
||||
FeaturesHash string `json:"features_hash"`
|
||||
PolicyHash string `json:"policy_hash"`
|
||||
Decision string `json:"decision"`
|
||||
Notes string `json:"notes"`
|
||||
ReviewedBy string `json:"reviewed_by"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type WarningOverrideRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Profile string `json:"profile"`
|
||||
Action string `json:"action"`
|
||||
PolicyHash string `json:"policy_hash"`
|
||||
Reason string `json:"reason"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type AdvisoryRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
AdvisoryID string `json:"advisory_id"`
|
||||
Status string `json:"status"`
|
||||
Action string `json:"action"`
|
||||
ArtifactSHA256 string `json:"artifact_sha256"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
VersionRange string `json:"version_range"`
|
||||
DependencyName string `json:"dependency_name"`
|
||||
DependencyRange string `json:"dependency_range"`
|
||||
RecommendedAction string `json:"recommended_action"`
|
||||
FixedVersion string `json:"fixed_version"`
|
||||
Mitigation string `json:"mitigation"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type PreflightRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Profile string `json:"profile"`
|
||||
Status string `json:"status"`
|
||||
ResultJSON string `json:"result_json"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type BenchmarkRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Profile string `json:"profile"`
|
||||
BenchmarkProfile string `json:"benchmark_profile"`
|
||||
P95MS float64 `json:"p95_ms"`
|
||||
P99MS float64 `json:"p99_ms"`
|
||||
ErrorRate float64 `json:"error_rate"`
|
||||
ActiveProxyCapacity int64 `json:"active_proxy_capacity"`
|
||||
BaselineDiff float64 `json:"baseline_diff"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type GovernanceReviewRequest struct {
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Profile string `json:"profile"`
|
||||
Decision string `json:"decision"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
type WarningOverrideRequest struct {
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Profile string `json:"profile"`
|
||||
Action string `json:"action"`
|
||||
Reason string `json:"reason"`
|
||||
TTLSeconds int64 `json:"ttl_seconds"`
|
||||
}
|
||||
|
||||
type PreflightRequest struct {
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Profile string `json:"profile"`
|
||||
Action string `json:"action"`
|
||||
ConfigJSON string `json:"config_json"`
|
||||
}
|
||||
|
||||
type SelfTestRequest struct {
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Profile string `json:"profile"`
|
||||
}
|
||||
|
||||
type AdvisoryRequest struct {
|
||||
AdvisoryID string `json:"advisory_id"`
|
||||
Status string `json:"status"`
|
||||
Action string `json:"action"`
|
||||
ArtifactSHA256 string `json:"artifact_sha256"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
VersionRange string `json:"version_range"`
|
||||
DependencyName string `json:"dependency_name"`
|
||||
DependencyRange string `json:"dependency_range"`
|
||||
RecommendedAction string `json:"recommended_action"`
|
||||
FixedVersion string `json:"fixed_version"`
|
||||
Mitigation string `json:"mitigation"`
|
||||
}
|
||||
|
||||
type BenchmarkRequest struct {
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Profile string `json:"profile"`
|
||||
BenchmarkProfile string `json:"benchmark_profile"`
|
||||
P95MS float64 `json:"p95_ms"`
|
||||
P99MS float64 `json:"p99_ms"`
|
||||
ErrorRate float64 `json:"error_rate"`
|
||||
ActiveProxyCapacity int64 `json:"active_proxy_capacity"`
|
||||
BaselineDiff float64 `json:"baseline_diff"`
|
||||
}
|
||||
|
||||
type ProxyConnectionSummary struct {
|
||||
ID uint64 `json:"id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
HandlerID string `json:"handler_id"`
|
||||
StartedAt int64 `json:"started_at"`
|
||||
DurationMS int64 `json:"duration_ms"`
|
||||
Draining bool `json:"draining"`
|
||||
}
|
||||
|
||||
type OperationRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Operation string `json:"operation"`
|
||||
Status string `json:"status"`
|
||||
Actor string `json:"actor"`
|
||||
Message string `json:"message"`
|
||||
MetadataJSON string `json:"metadata_json"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type BuildRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
SourceID string `json:"source_id"`
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Status string `json:"status"`
|
||||
BuilderType string `json:"builder_type"`
|
||||
BuilderImage string `json:"builder_image"`
|
||||
BuilderVersion string `json:"builder_version"`
|
||||
GoVersion string `json:"go_version"`
|
||||
GOOS string `json:"go_os"`
|
||||
GOARCH string `json:"go_arch"`
|
||||
GOAMD64 string `json:"go_amd64"`
|
||||
GOARM64 string `json:"go_arm64"`
|
||||
CGOEnabled string `json:"cgo_enabled"`
|
||||
BuildTags string `json:"build_tags"`
|
||||
SDKModule string `json:"sdk_module"`
|
||||
SDKVersion string `json:"sdk_version"`
|
||||
GOPROXY string `json:"go_proxy"`
|
||||
GONOSUMDB string `json:"go_no_sumdb"`
|
||||
GOPRIVATE string `json:"go_private"`
|
||||
VendorRequired bool `json:"vendor_required"`
|
||||
SourceSHA256 string `json:"source_sha256"`
|
||||
ArtifactSHA256 string `json:"artifact_sha256"`
|
||||
ModuleSummary string `json:"module_summary_json"`
|
||||
GoVersionM string `json:"go_version_m_json"`
|
||||
ABIFingerprint string `json:"abi_fingerprint"`
|
||||
LogSummary string `json:"log_summary"`
|
||||
MetadataJSON string `json:"metadata_json"`
|
||||
Error string `json:"error"`
|
||||
StartedAt int64 `json:"started_at"`
|
||||
EndedAt int64 `json:"ended_at"`
|
||||
DurationMS int64 `json:"duration_ms"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type BuildRequest struct {
|
||||
SourceID string `json:"source_id"`
|
||||
BuilderType string `json:"builder_type"`
|
||||
BuilderImage string `json:"builder_image"`
|
||||
BuilderVersion string `json:"builder_version"`
|
||||
GOOS string `json:"go_os"`
|
||||
GOARCH string `json:"go_arch"`
|
||||
GOAMD64 string `json:"go_amd64"`
|
||||
GOARM64 string `json:"go_arm64"`
|
||||
CGOEnabled string `json:"cgo_enabled"`
|
||||
BuildTags string `json:"build_tags"`
|
||||
SDKModule string `json:"sdk_module"`
|
||||
SDKVersion string `json:"sdk_version"`
|
||||
GOPROXY string `json:"go_proxy"`
|
||||
GONOSUMDB string `json:"go_no_sumdb"`
|
||||
GOPRIVATE string `json:"go_private"`
|
||||
VendorRequired bool `json:"vendor_required"`
|
||||
}
|
||||
|
||||
type GCCandidate struct {
|
||||
Kind string `json:"kind"`
|
||||
ID string `json:"id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
Path string `json:"path"`
|
||||
Protected bool `json:"protected"`
|
||||
Reason string `json:"reason"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
Referenced bool `json:"referenced"`
|
||||
}
|
||||
|
||||
type ConfigSnapshot struct {
|
||||
ID int64 `json:"id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
ConfigJSON string `json:"config_json"`
|
||||
DesiredState string `json:"desired_state"`
|
||||
Priority int `json:"priority"`
|
||||
DesiredGeneration int64 `json:"desired_generation"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type DispatchPlan struct {
|
||||
Handlers []DispatchHandlerSummary `json:"handlers"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type DispatchHandlerSummary struct {
|
||||
PluginID string `json:"plugin_id"`
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Priority int `json:"priority"`
|
||||
HandlerID string `json:"handler_id"`
|
||||
ExtensionPoint string `json:"extension_point"`
|
||||
Mode string `json:"mode"`
|
||||
TimeoutMS int64 `json:"timeout_ms"`
|
||||
Calls uint64 `json:"calls"`
|
||||
Errors uint64 `json:"errors"`
|
||||
Panics uint64 `json:"panics"`
|
||||
Timeouts uint64 `json:"timeouts"`
|
||||
Blocked uint64 `json:"blocked"`
|
||||
ActiveProxy int64 `json:"active_proxy_connections"`
|
||||
ProxyStarted uint64 `json:"proxy_connections_started"`
|
||||
ProxyCompleted uint64 `json:"proxy_connections_completed"`
|
||||
ProxyErrors uint64 `json:"proxy_errors"`
|
||||
ProxyBytesIn uint64 `json:"proxy_bytes_in"`
|
||||
ProxyBytesOut uint64 `json:"proxy_bytes_out"`
|
||||
ProxyDurationMS uint64 `json:"proxy_duration_ms"`
|
||||
DurationCount uint64 `json:"duration_count"`
|
||||
DurationSumMS uint64 `json:"duration_sum_ms"`
|
||||
DurationMaxMS uint64 `json:"duration_max_ms"`
|
||||
}
|
||||
|
||||
type OperationsSnapshot struct {
|
||||
PluginID string `json:"plugin_id,omitempty"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
Handlers []DispatchHandlerSummary `json:"handlers"`
|
||||
Builds []BuildMetricSummary `json:"builds"`
|
||||
Events []EventSummary `json:"events"`
|
||||
CustomMetrics []CustomMetricSummary `json:"custom_metrics"`
|
||||
Logs []LogSummary `json:"logs"`
|
||||
Traces []TraceSummary `json:"traces"`
|
||||
BackgroundTasks []BackgroundTaskSummary `json:"background_tasks"`
|
||||
PluginData []PluginDataSummary `json:"plugin_data"`
|
||||
PluginFiles []PluginFileSummary `json:"plugin_files"`
|
||||
ExternalDependencies []ExternalDependencySummary `json:"external_dependencies"`
|
||||
GC []GCCandidate `json:"gc,omitempty"`
|
||||
EventQueue EventQueueSummary `json:"event_queue"`
|
||||
Diagnostics []DiagnosticPackageSummary `json:"diagnostics,omitempty"`
|
||||
}
|
||||
|
||||
type BuildMetricSummary struct {
|
||||
PluginID string `json:"plugin_id"`
|
||||
BuildID int64 `json:"build_id"`
|
||||
Status string `json:"status"`
|
||||
DurationMS int64 `json:"duration_ms"`
|
||||
Failed bool `json:"failed"`
|
||||
ErrorRedacted string `json:"error_redacted,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type EventSummary struct {
|
||||
PluginID string `json:"plugin_id"`
|
||||
Name string `json:"name"`
|
||||
Count uint64 `json:"count"`
|
||||
Dropped uint64 `json:"dropped"`
|
||||
DeadLetters uint64 `json:"dead_letters"`
|
||||
Fields map[string]string `json:"fields,omitempty"`
|
||||
LastSeenAt int64 `json:"last_seen_at"`
|
||||
}
|
||||
|
||||
type EventQueueSummary struct {
|
||||
Limit int `json:"limit"`
|
||||
Queued int `json:"queued"`
|
||||
Dropped uint64 `json:"dropped"`
|
||||
DeadLetters uint64 `json:"dead_letters"`
|
||||
}
|
||||
|
||||
type CustomMetricSummary struct {
|
||||
PluginID string `json:"plugin_id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Count uint64 `json:"count"`
|
||||
LastValue float64 `json:"last_value"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
LastSeenAt int64 `json:"last_seen_at"`
|
||||
}
|
||||
|
||||
type LogSummary struct {
|
||||
PluginID string `json:"plugin_id"`
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
Fields map[string]string `json:"fields,omitempty"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
ConnectionID string `json:"connection_id,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type TraceSummary struct {
|
||||
PluginID string `json:"plugin_id"`
|
||||
TraceID string `json:"trace_id"`
|
||||
ConnectionID string `json:"connection_id"`
|
||||
HandlerID string `json:"handler_id,omitempty"`
|
||||
Operation string `json:"operation"`
|
||||
Status string `json:"status"`
|
||||
DurationMS int64 `json:"duration_ms"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type BackgroundTaskSummary struct {
|
||||
PluginID string `json:"plugin_id"`
|
||||
TaskID string `json:"task_id"`
|
||||
Name string `json:"name"`
|
||||
Mode string `json:"mode"`
|
||||
IntervalMS int64 `json:"interval_ms"`
|
||||
RunOnStart bool `json:"run_on_start"`
|
||||
TimeoutMS int64 `json:"timeout_ms"`
|
||||
Manual bool `json:"manual"`
|
||||
Running bool `json:"running"`
|
||||
LastRunAt int64 `json:"last_run_at"`
|
||||
NextRunAt int64 `json:"next_run_at"`
|
||||
LastDurationMS int64 `json:"last_duration_ms"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
Skipped uint64 `json:"skipped"`
|
||||
ConsecutiveFailures uint64 `json:"consecutive_failures"`
|
||||
}
|
||||
|
||||
type PluginDataSummary struct {
|
||||
PluginID string `json:"plugin_id"`
|
||||
Key string `json:"key"`
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
DataClass string `json:"data_class"`
|
||||
Exportable bool `json:"exportable"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type PluginDataRecord struct {
|
||||
PluginDataSummary
|
||||
Value []byte `json:"-"`
|
||||
}
|
||||
|
||||
type PluginFileSummary struct {
|
||||
PluginID string `json:"plugin_id"`
|
||||
Namespace string `json:"namespace"`
|
||||
Path string `json:"path"`
|
||||
DataClass string `json:"data_class"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
Orphaned bool `json:"orphaned"`
|
||||
Readonly bool `json:"readonly"`
|
||||
}
|
||||
|
||||
type PluginFileRecord struct {
|
||||
PluginFileSummary
|
||||
}
|
||||
|
||||
type ExternalDependencySummary struct {
|
||||
PluginID string `json:"plugin_id"`
|
||||
Name string `json:"name"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Purpose string `json:"purpose"`
|
||||
Required bool `json:"required"`
|
||||
FailPolicy string `json:"fail_policy"`
|
||||
DataClasses []string `json:"data_classes,omitempty"`
|
||||
Requests uint64 `json:"requests"`
|
||||
Errors uint64 `json:"errors"`
|
||||
Inflight int64 `json:"inflight"`
|
||||
DurationCount uint64 `json:"duration_count"`
|
||||
DurationSumMS uint64 `json:"duration_sum_ms"`
|
||||
CircuitState string `json:"circuit_state"`
|
||||
ConsecutiveFailures uint64 `json:"consecutive_failures"`
|
||||
RecentError string `json:"recent_error,omitempty"`
|
||||
LastStatus string `json:"last_status,omitempty"`
|
||||
LastSeenAt int64 `json:"last_seen_at"`
|
||||
}
|
||||
|
||||
type DiagnosticPackageSummary struct {
|
||||
PluginID string `json:"plugin_id"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
Sections []string `json:"sections"`
|
||||
}
|
||||
|
||||
type UpstreamResult struct {
|
||||
Conn net.Conn
|
||||
Handled bool
|
||||
Mode string
|
||||
PluginID string
|
||||
HandlerID string
|
||||
InitialDataSent bool
|
||||
Proxied bool
|
||||
}
|
||||
|
||||
type Gateway struct {
|
||||
PluginID string
|
||||
handleConn func(net.Conn)
|
||||
wg *sync.WaitGroup
|
||||
hooks map[string]any
|
||||
ops *PluginOperations
|
||||
}
|
||||
|
||||
func NewGateway(pluginID string, handleConn func(net.Conn), wg *sync.WaitGroup, ops *PluginOperations) *Gateway {
|
||||
return &Gateway{
|
||||
PluginID: pluginID,
|
||||
handleConn: handleConn,
|
||||
wg: wg,
|
||||
hooks: make(map[string]any),
|
||||
ops: ops,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) RegisteredHooks() map[string]any {
|
||||
copied := make(map[string]any, len(g.hooks))
|
||||
for key, value := range g.hooks {
|
||||
copied[key] = value
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
func (g *Gateway) Hook(hook string, handler any) error {
|
||||
g.hooks[hook] = handler
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *Gateway) HandleConn(conn net.Conn) {
|
||||
if g.handleConn != nil {
|
||||
g.handleConn(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) ExitWaitGroup() *sync.WaitGroup {
|
||||
if g.wg == nil {
|
||||
g.wg = &sync.WaitGroup{}
|
||||
}
|
||||
return g.wg
|
||||
}
|
||||
|
||||
func (g *Gateway) EmitEvent(ctx context.Context, name string, fields map[string]string) error {
|
||||
if g.ops == nil {
|
||||
return nil
|
||||
}
|
||||
return g.ops.EmitEvent(ctx, name, fields)
|
||||
}
|
||||
|
||||
func (g *Gateway) ObserveMetric(ctx context.Context, name string, value float64, labels map[string]string) error {
|
||||
if g.ops == nil {
|
||||
return nil
|
||||
}
|
||||
return g.ops.ObserveMetric(ctx, name, value, labels)
|
||||
}
|
||||
|
||||
func (g *Gateway) Logger() api.Logger {
|
||||
if g.ops == nil {
|
||||
return noopOperationsLogger{}
|
||||
}
|
||||
return g.ops.Logger()
|
||||
}
|
||||
|
||||
func (g *Gateway) DataStore() api.DataStore {
|
||||
if g.ops == nil {
|
||||
return noopOperationsDataStore{}
|
||||
}
|
||||
return g.ops.DataStore()
|
||||
}
|
||||
|
||||
func (g *Gateway) FileStore() api.FileStore {
|
||||
if g.ops == nil {
|
||||
return noopOperationsFileStore{}
|
||||
}
|
||||
return g.ops.FileStore()
|
||||
}
|
||||
|
||||
func (g *Gateway) ExternalClient(name string) api.ExternalClient {
|
||||
if g.ops == nil {
|
||||
return noopOperationsExternalClient{}
|
||||
}
|
||||
return g.ops.ExternalClient(name)
|
||||
}
|
||||
|
||||
func (g *Gateway) RegisterBackgroundTask(task api.BackgroundTask) error {
|
||||
if g.ops == nil {
|
||||
return nil
|
||||
}
|
||||
return g.ops.RegisterBackgroundTask(task)
|
||||
}
|
||||
|
||||
func (g *Gateway) LegacyUpstreamHandler() (api.HookHandler[func(net.Conn, string) bool, func(net.Conn, string) (net.Conn, error)], bool) {
|
||||
handler, ok := g.hooks[api.HookUpstream.Key()].(api.HookHandler[func(net.Conn, string) bool, func(net.Conn, string) (net.Conn, error)])
|
||||
return handler, ok
|
||||
}
|
||||
|
||||
func (g *Gateway) UpstreamConnectHandler() (api.HookHandler[api.UpstreamConnectAcceptor, api.UpstreamConnectHandler], bool) {
|
||||
handler, ok := g.hooks[api.HookUpstreamConnect.Key()].(api.HookHandler[api.UpstreamConnectAcceptor, api.UpstreamConnectHandler])
|
||||
return handler, ok
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type (
|
||||
@@ -23,12 +27,129 @@ type (
|
||||
ReloadConfig(config any) error
|
||||
}
|
||||
|
||||
PreflightCheck struct {
|
||||
Code string `json:"code"`
|
||||
Severity string `json:"severity"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
PreflightContext struct {
|
||||
PluginID string `json:"plugin_id"`
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Profile string `json:"profile"`
|
||||
Action string `json:"action"`
|
||||
Config map[string]any `json:"config,omitempty"`
|
||||
Scope any `json:"scope,omitempty"`
|
||||
Rollout any `json:"rollout,omitempty"`
|
||||
RuntimeLimits any `json:"runtime_limits,omitempty"`
|
||||
Features []string `json:"features,omitempty"`
|
||||
}
|
||||
|
||||
PreflightResult struct {
|
||||
Checks []PreflightCheck `json:"checks"`
|
||||
}
|
||||
|
||||
SelfTestProfile struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
SelfTestResult struct {
|
||||
Checks []PreflightCheck `json:"checks"`
|
||||
}
|
||||
|
||||
PreflightChecker interface {
|
||||
Preflight(context any) (PreflightResult, error)
|
||||
}
|
||||
|
||||
SelfTester interface {
|
||||
SelfTest(profile SelfTestProfile) (SelfTestResult, error)
|
||||
}
|
||||
|
||||
Gateway interface {
|
||||
HandleConn(conn net.Conn)
|
||||
|
||||
ExitWaitGroup() *sync.WaitGroup
|
||||
|
||||
Hook(hook string, handler any) error
|
||||
|
||||
EmitEvent(ctx context.Context, name string, fields map[string]string) error
|
||||
ObserveMetric(ctx context.Context, name string, value float64, labels map[string]string) error
|
||||
Logger() Logger
|
||||
DataStore() DataStore
|
||||
FileStore() FileStore
|
||||
ExternalClient(name string) ExternalClient
|
||||
RegisterBackgroundTask(task BackgroundTask) error
|
||||
}
|
||||
|
||||
EventSchema struct {
|
||||
Name string `json:"name"`
|
||||
Fields []string `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
CustomMetricSchema struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
}
|
||||
|
||||
Logger interface {
|
||||
Debug(ctx context.Context, message string, fields map[string]string)
|
||||
Info(ctx context.Context, message string, fields map[string]string)
|
||||
Warn(ctx context.Context, message string, fields map[string]string)
|
||||
Error(ctx context.Context, message string, fields map[string]string)
|
||||
}
|
||||
|
||||
DataRecord struct {
|
||||
Key string
|
||||
Value []byte
|
||||
SchemaVersion int
|
||||
DataClass string
|
||||
Exportable bool
|
||||
Retention time.Duration
|
||||
}
|
||||
|
||||
DataStore interface {
|
||||
Put(ctx context.Context, record DataRecord) error
|
||||
Get(ctx context.Context, key string) (DataRecord, error)
|
||||
Delete(ctx context.Context, key string) error
|
||||
}
|
||||
|
||||
FileStore interface {
|
||||
ResourcePath(name string) (string, error)
|
||||
Write(ctx context.Context, namespace, name string, data []byte, dataClass string, retention time.Duration) error
|
||||
Read(ctx context.Context, namespace, name string, maxBytes int64) ([]byte, error)
|
||||
Delete(ctx context.Context, namespace, name string) error
|
||||
}
|
||||
|
||||
ExternalRequest struct {
|
||||
Method string
|
||||
URL string
|
||||
Header http.Header
|
||||
Body io.Reader
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
ExternalResponse struct {
|
||||
StatusCode int
|
||||
Header http.Header
|
||||
Body []byte
|
||||
}
|
||||
|
||||
ExternalClient interface {
|
||||
DoHTTP(ctx context.Context, req ExternalRequest) (ExternalResponse, error)
|
||||
DialTCP(ctx context.Context, address string, timeout time.Duration) (net.Conn, error)
|
||||
HealthCheck(ctx context.Context) error
|
||||
}
|
||||
|
||||
BackgroundTask struct {
|
||||
ID string
|
||||
Name string
|
||||
Interval time.Duration
|
||||
RunOnStart bool
|
||||
Jitter time.Duration
|
||||
Timeout time.Duration
|
||||
Manual bool
|
||||
Run func(context.Context) error
|
||||
}
|
||||
|
||||
AbstractPlugin struct{}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAbstractPluginDefaults(t *testing.T) {
|
||||
@@ -24,6 +26,9 @@ func TestAbstractPluginDefaults(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHookTypesAndHandlers(t *testing.T) {
|
||||
if got := HookUpstreamConnect.Key(); got != "upstream.connect/v1" {
|
||||
t.Fatalf("HookUpstreamConnect.Key() = %q, want upstream.connect/v1", got)
|
||||
}
|
||||
if got := HookUpstream.Key(); got != "upstream" {
|
||||
t.Fatalf("HookUpstream.Key() = %q, want upstream", got)
|
||||
}
|
||||
@@ -89,3 +94,67 @@ func (g *recordingGateway) Hook(hook string, handler any) error {
|
||||
g.hooks[hook] = handler
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *recordingGateway) EmitEvent(context.Context, string, map[string]string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *recordingGateway) ObserveMetric(context.Context, string, float64, map[string]string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *recordingGateway) Logger() Logger {
|
||||
return testLogger{}
|
||||
}
|
||||
|
||||
func (g *recordingGateway) DataStore() DataStore {
|
||||
return testDataStore{}
|
||||
}
|
||||
|
||||
func (g *recordingGateway) FileStore() FileStore {
|
||||
return testFileStore{}
|
||||
}
|
||||
|
||||
func (g *recordingGateway) ExternalClient(string) ExternalClient {
|
||||
return testExternalClient{}
|
||||
}
|
||||
|
||||
func (g *recordingGateway) RegisterBackgroundTask(BackgroundTask) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type testLogger struct{}
|
||||
|
||||
func (testLogger) Debug(context.Context, string, map[string]string) {}
|
||||
func (testLogger) Info(context.Context, string, map[string]string) {}
|
||||
func (testLogger) Warn(context.Context, string, map[string]string) {}
|
||||
func (testLogger) Error(context.Context, string, map[string]string) {}
|
||||
|
||||
type testDataStore struct{}
|
||||
|
||||
func (testDataStore) Put(context.Context, DataRecord) error { return nil }
|
||||
func (testDataStore) Get(context.Context, string) (DataRecord, error) {
|
||||
return DataRecord{}, nil
|
||||
}
|
||||
func (testDataStore) Delete(context.Context, string) error { return nil }
|
||||
|
||||
type testFileStore struct{}
|
||||
|
||||
func (testFileStore) ResourcePath(string) (string, error) { return "", nil }
|
||||
func (testFileStore) Write(context.Context, string, string, []byte, string, time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
func (testFileStore) Read(context.Context, string, string, int64) ([]byte, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (testFileStore) Delete(context.Context, string, string) error { return nil }
|
||||
|
||||
type testExternalClient struct{}
|
||||
|
||||
func (testExternalClient) DoHTTP(context.Context, ExternalRequest) (ExternalResponse, error) {
|
||||
return ExternalResponse{}, nil
|
||||
}
|
||||
func (testExternalClient) DialTCP(context.Context, string, time.Duration) (net.Conn, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (testExternalClient) HealthCheck(context.Context) error { return nil }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"unsafe"
|
||||
@@ -8,9 +9,14 @@ import (
|
||||
|
||||
var (
|
||||
UnsupportedHookType = errors.New("unsupported hook type")
|
||||
ErrPass = errors.New("plugin handler pass")
|
||||
ErrBlocked = errors.New("plugin handler blocked")
|
||||
)
|
||||
|
||||
type (
|
||||
ConnectionIDContextKey struct{}
|
||||
TraceIDContextKey struct{}
|
||||
|
||||
HookType[Accept, Handler any] struct {
|
||||
key string
|
||||
}
|
||||
@@ -19,9 +25,43 @@ type (
|
||||
acceptor Accept
|
||||
handler Handler
|
||||
}
|
||||
|
||||
UpstreamConnectRequest struct {
|
||||
Context context.Context
|
||||
Source net.Conn
|
||||
Host string
|
||||
Upstream string
|
||||
InitialData []byte
|
||||
Metadata map[string]string
|
||||
ConnectionID string
|
||||
TraceID string
|
||||
SourceAddr string
|
||||
ServerHost string
|
||||
RawServerHost string
|
||||
ProtocolVersion int
|
||||
NextState int
|
||||
RouteID string
|
||||
RouteTags []string
|
||||
UpstreamRaw string
|
||||
UpstreamProtocol string
|
||||
UpstreamAddress string
|
||||
Transport string
|
||||
ServiceName string
|
||||
ListenerPort int
|
||||
}
|
||||
|
||||
UpstreamConnectAcceptor func(UpstreamConnectRequest) bool
|
||||
UpstreamConnectHandler func(UpstreamConnectRequest) (net.Conn, error)
|
||||
)
|
||||
|
||||
var (
|
||||
HookUpstreamConnect = HookType[
|
||||
UpstreamConnectAcceptor,
|
||||
UpstreamConnectHandler,
|
||||
]{
|
||||
key: "upstream.connect/v1",
|
||||
}
|
||||
|
||||
HookUpstream = HookType[
|
||||
func(source net.Conn, host string) bool,
|
||||
func(source net.Conn, host string) (net.Conn, error),
|
||||
|
||||
177
protocol/mc.go
177
protocol/mc.go
@@ -2,59 +2,172 @@ package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Handshake struct {
|
||||
RawServerHost string
|
||||
ServerHost string
|
||||
ProtocolVersion int
|
||||
NextState int
|
||||
}
|
||||
|
||||
// ReplaceMcHost 替换 Minecraft 主机名
|
||||
// 必须是连接的第一个数据包
|
||||
func ReplaceMcHost(buf []byte, host string) []byte {
|
||||
if len(buf) < 5 {
|
||||
packet, consumed, err := readPacket(buf)
|
||||
if err != nil || len(packet) == 0 {
|
||||
return nil
|
||||
}
|
||||
packetID, n, err := readVarInt(packet)
|
||||
if err != nil || packetID != 0 {
|
||||
return nil
|
||||
}
|
||||
prefixEnd := n
|
||||
if _, n, err = readVarInt(packet[prefixEnd:]); err != nil {
|
||||
return nil
|
||||
}
|
||||
prefixEnd += n
|
||||
rawHost, n, err := readString(packet[prefixEnd:])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
hostEnd := prefixEnd + n
|
||||
|
||||
if spliterIndex := strings.IndexRune(rawHost, 0); spliterIndex != -1 {
|
||||
host = host + rawHost[spliterIndex:]
|
||||
}
|
||||
|
||||
var payload bytes.Buffer
|
||||
payload.Write(packet[:prefixEnd])
|
||||
payload.Write(encodeVarInt(len(host)))
|
||||
payload.WriteString(host)
|
||||
payload.Write(packet[hostEnd:])
|
||||
|
||||
var out bytes.Buffer
|
||||
head := buf[:4]
|
||||
|
||||
buf = buf[4:]
|
||||
host_len := buf[0]
|
||||
if len(buf) < int(host_len)+1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
raw_host := string(buf[1 : host_len+1])
|
||||
if spliterIndex := strings.IndexRune(raw_host, 0); spliterIndex != -1 {
|
||||
host = host + raw_host[spliterIndex:]
|
||||
}
|
||||
|
||||
// 修改标识数据包长度的字节
|
||||
head[0] += byte(len(host) - len(raw_host))
|
||||
|
||||
out.Write(head) // 保留前四个字节
|
||||
out.WriteByte(byte(len(host))) // 写入主机名长度
|
||||
out.Write([]byte(host)) // 写入主机名
|
||||
|
||||
out.Write(buf[host_len+1:]) // 写入剩余数据
|
||||
out.Write(encodeVarInt(payload.Len()))
|
||||
out.Write(payload.Bytes())
|
||||
out.Write(buf[consumed:])
|
||||
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
// GetMcHost 通过第一个数据包获取 Minecraft 主机名
|
||||
func GetMcHost(buf []byte) string {
|
||||
if len(buf) < 5 {
|
||||
return ""
|
||||
return ParseHandshake(buf).ServerHost
|
||||
}
|
||||
|
||||
func ParseHandshake(buf []byte) Handshake {
|
||||
packet, _, err := readPacket(buf)
|
||||
if err != nil || len(packet) == 0 {
|
||||
return Handshake{}
|
||||
}
|
||||
packetID, n, err := readVarInt(packet)
|
||||
if err != nil || packetID != 0 {
|
||||
return Handshake{}
|
||||
}
|
||||
packet = packet[n:]
|
||||
protocolVersion, n, err := readVarInt(packet)
|
||||
if err != nil {
|
||||
return Handshake{}
|
||||
}
|
||||
packet = packet[n:]
|
||||
host, n, err := readString(packet)
|
||||
if err != nil {
|
||||
return Handshake{}
|
||||
}
|
||||
packet = packet[n:]
|
||||
if _, n, err = readUnsignedShort(packet); err != nil {
|
||||
return Handshake{}
|
||||
}
|
||||
packet = packet[n:]
|
||||
nextState, _, err := readVarInt(packet)
|
||||
if err != nil {
|
||||
return Handshake{}
|
||||
}
|
||||
|
||||
buf = buf[4:]
|
||||
host_len := buf[0]
|
||||
if len(buf) < int(host_len)+1 {
|
||||
return ""
|
||||
parsed := Handshake{
|
||||
RawServerHost: host,
|
||||
ProtocolVersion: protocolVersion,
|
||||
NextState: nextState,
|
||||
}
|
||||
|
||||
host := string(buf[1 : host_len+1])
|
||||
|
||||
if spliterIndex := strings.IndexRune(host, 0); spliterIndex != -1 {
|
||||
return host[0:spliterIndex]
|
||||
parsed.ServerHost = host[0:spliterIndex]
|
||||
} else {
|
||||
return host
|
||||
parsed.ServerHost = host
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func ReadPacket(buf []byte) ([]byte, int, error) {
|
||||
return readPacket(buf)
|
||||
}
|
||||
|
||||
func ReadVarInt(buf []byte) (int, int, error) {
|
||||
return readVarInt(buf)
|
||||
}
|
||||
|
||||
func ReadString(buf []byte) (string, int, error) {
|
||||
return readString(buf)
|
||||
}
|
||||
|
||||
func readPacket(buf []byte) ([]byte, int, error) {
|
||||
length, n, err := readVarInt(buf)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if length < 0 || len(buf[n:]) < length {
|
||||
return nil, 0, errors.New("incomplete packet")
|
||||
}
|
||||
return buf[n : n+length], n + length, nil
|
||||
}
|
||||
|
||||
func readVarInt(buf []byte) (int, int, error) {
|
||||
var value int
|
||||
for i := 0; i < 5; i++ {
|
||||
if i >= len(buf) {
|
||||
return 0, 0, errors.New("incomplete varint")
|
||||
}
|
||||
b := buf[i]
|
||||
value |= int(b&0x7f) << (7 * i)
|
||||
if b&0x80 == 0 {
|
||||
return value, i + 1, nil
|
||||
}
|
||||
}
|
||||
return 0, 0, errors.New("varint too long")
|
||||
}
|
||||
|
||||
func readString(buf []byte) (string, int, error) {
|
||||
length, n, err := readVarInt(buf)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if length < 0 || len(buf[n:]) < length {
|
||||
return "", 0, errors.New("incomplete string")
|
||||
}
|
||||
return string(buf[n : n+length]), n + length, nil
|
||||
}
|
||||
|
||||
func readUnsignedShort(buf []byte) (int, int, error) {
|
||||
if len(buf) < 2 {
|
||||
return 0, 0, errors.New("incomplete unsigned short")
|
||||
}
|
||||
return int(buf[0])<<8 | int(buf[1]), 2, nil
|
||||
}
|
||||
|
||||
func encodeVarInt(value int) []byte {
|
||||
var out []byte
|
||||
for {
|
||||
b := byte(value & 0x7f)
|
||||
value >>= 7
|
||||
if value != 0 {
|
||||
b |= 0x80
|
||||
}
|
||||
out = append(out, b)
|
||||
if value == 0 {
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,14 +92,22 @@ func TestReplaceMcHost(t *testing.T) {
|
||||
}
|
||||
|
||||
func mcTestPacket(host string, tail ...byte) []byte {
|
||||
packet := []byte{
|
||||
byte(4 + 1 + len(host) + len(tail)),
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
byte(len(host)),
|
||||
protocolVersion := byte(0x63)
|
||||
nextState := byte(0x02)
|
||||
extra := []byte(nil)
|
||||
if len(tail) > 0 {
|
||||
protocolVersion = tail[0]
|
||||
}
|
||||
packet = append(packet, host...)
|
||||
packet = append(packet, tail...)
|
||||
return packet
|
||||
if len(tail) > 1 {
|
||||
nextState = tail[1]
|
||||
}
|
||||
if len(tail) > 2 {
|
||||
extra = tail[2:]
|
||||
}
|
||||
payload := []byte{0x00, protocolVersion, byte(len(host))}
|
||||
payload = append(payload, host...)
|
||||
payload = append(payload, 0x63, 0xdd, nextState)
|
||||
payload = append(payload, extra...)
|
||||
packet := []byte{byte(len(payload))}
|
||||
return append(packet, payload...)
|
||||
}
|
||||
|
||||
53
protocol/smoke/helper.go
Normal file
53
protocol/smoke/helper.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package smoke
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
Request []byte
|
||||
Response []byte
|
||||
}
|
||||
|
||||
func RunProtocolProxyFixture(initial []byte, handler func(net.Conn)) (Result, error) {
|
||||
gatewayEnd, pluginEnd := net.Pipe()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
handler(pluginEnd)
|
||||
}()
|
||||
defer gatewayEnd.Close()
|
||||
|
||||
if err := gatewayEnd.SetDeadline(time.Now().Add(2 * time.Second)); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if _, err := gatewayEnd.Write(initial); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
var response bytes.Buffer
|
||||
readDone := make(chan error, 1)
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
n, err := gatewayEnd.Read(buf)
|
||||
if n > 0 {
|
||||
_, _ = response.Write(buf[:n])
|
||||
}
|
||||
if err == io.EOF {
|
||||
err = nil
|
||||
}
|
||||
readDone <- err
|
||||
}()
|
||||
<-done
|
||||
_ = gatewayEnd.Close()
|
||||
if err := <-readDone; err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return Result{
|
||||
Request: append([]byte(nil), initial...),
|
||||
Response: response.Bytes(),
|
||||
}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user