Compare commits

..

7 Commits

Author SHA1 Message Date
tursom
91eee02243 feat(plugin): add observability operations
Some checks failed
Go / build (.exe, 386, windows, windows-386) (push) Has been cancelled
Go / build (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Go / build (.exe, arm64, windows, windows-arm64) (push) Has been cancelled
Go / build (386, freebsd, freebsd-386) (push) Has been cancelled
Go / build (386, linux, linux-386) (push) Has been cancelled
Go / build (386, netbsd, netbsd-386) (push) Has been cancelled
Go / build (386, openbsd, openbsd-386) (push) Has been cancelled
Go / build (386, plan9, plan9-386) (push) Has been cancelled
Go / build (amd64, darwin, darwin-amd64) (push) Has been cancelled
Go / build (amd64, dragonfly, dragonfly-amd64) (push) Has been cancelled
Go / build (amd64, freebsd, freebsd-amd64) (push) Has been cancelled
Go / build (amd64, illumos, illumos-amd64) (push) Has been cancelled
Go / build (amd64, linux, linux-amd64) (push) Has been cancelled
Go / build (amd64, netbsd, netbsd-amd64) (push) Has been cancelled
Go / build (amd64, openbsd, openbsd-amd64) (push) Has been cancelled
Go / build (amd64, plan9, plan9-amd64) (push) Has been cancelled
Go / build (amd64, solaris, solaris-amd64) (push) Has been cancelled
Go / build (arm, 6, linux, linux-armv6) (push) Has been cancelled
Go / build (arm, 7, linux, linux-armv7) (push) Has been cancelled
Go / build (arm, freebsd, freebsd-arm) (push) Has been cancelled
Go / build (arm, netbsd, netbsd-arm) (push) Has been cancelled
Go / build (arm, openbsd, openbsd-arm) (push) Has been cancelled
Go / build (arm, plan9, plan9-arm) (push) Has been cancelled
Go / build (arm64, darwin, darwin-arm64) (push) Has been cancelled
Go / build (arm64, freebsd, freebsd-arm64) (push) Has been cancelled
Go / build (arm64, linux, linux-arm64) (push) Has been cancelled
Go / build (arm64, netbsd, netbsd-arm64) (push) Has been cancelled
Go / build (arm64, openbsd, openbsd-arm64) (push) Has been cancelled
Go / build (loong64, linux, linux-loong64) (push) Has been cancelled
Go / build (mips, linux, linux-mips) (push) Has been cancelled
Go / build (mips64, linux, linux-mips64) (push) Has been cancelled
Go / build (mips64le, linux, linux-mips64le) (push) Has been cancelled
Go / build (mipsle, linux, linux-mipsle) (push) Has been cancelled
Go / build (ppc64, aix, aix-ppc64) (push) Has been cancelled
Go / build (ppc64, linux, linux-ppc64) (push) Has been cancelled
Go / build (ppc64, openbsd, openbsd-ppc64) (push) Has been cancelled
Go / build (ppc64le, linux, linux-ppc64le) (push) Has been cancelled
Go / build (riscv64, freebsd, freebsd-riscv64) (push) Has been cancelled
Go / build (riscv64, linux, linux-riscv64) (push) Has been cancelled
Go / build (riscv64, openbsd, openbsd-riscv64) (push) Has been cancelled
Go / build (s390x, linux, linux-s390x) (push) Has been cancelled
Go / merge-artifacts (push) Has been cancelled
Docker Image / docker (push) Has been cancelled
2026-06-26 12:32:45 +08:00
tursom
629d6d5dbc feat(plugin): add governance release gates
Some checks failed
Go / build (.exe, 386, windows, windows-386) (push) Has been cancelled
Go / build (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Go / build (.exe, arm64, windows, windows-arm64) (push) Has been cancelled
Go / build (386, freebsd, freebsd-386) (push) Has been cancelled
Go / build (386, linux, linux-386) (push) Has been cancelled
Go / build (386, netbsd, netbsd-386) (push) Has been cancelled
Go / build (386, openbsd, openbsd-386) (push) Has been cancelled
Go / build (386, plan9, plan9-386) (push) Has been cancelled
Go / build (amd64, darwin, darwin-amd64) (push) Has been cancelled
Go / build (amd64, dragonfly, dragonfly-amd64) (push) Has been cancelled
Go / build (amd64, freebsd, freebsd-amd64) (push) Has been cancelled
Go / build (amd64, illumos, illumos-amd64) (push) Has been cancelled
Go / build (amd64, linux, linux-amd64) (push) Has been cancelled
Go / build (amd64, netbsd, netbsd-amd64) (push) Has been cancelled
Go / build (amd64, openbsd, openbsd-amd64) (push) Has been cancelled
Go / build (amd64, plan9, plan9-amd64) (push) Has been cancelled
Go / build (amd64, solaris, solaris-amd64) (push) Has been cancelled
Go / build (arm, 6, linux, linux-armv6) (push) Has been cancelled
Go / build (arm, 7, linux, linux-armv7) (push) Has been cancelled
Go / build (arm, freebsd, freebsd-arm) (push) Has been cancelled
Go / build (arm, netbsd, netbsd-arm) (push) Has been cancelled
Go / build (arm, openbsd, openbsd-arm) (push) Has been cancelled
Go / build (arm, plan9, plan9-arm) (push) Has been cancelled
Go / build (arm64, darwin, darwin-arm64) (push) Has been cancelled
Go / build (arm64, freebsd, freebsd-arm64) (push) Has been cancelled
Go / build (arm64, linux, linux-arm64) (push) Has been cancelled
Go / build (arm64, netbsd, netbsd-arm64) (push) Has been cancelled
Go / build (arm64, openbsd, openbsd-arm64) (push) Has been cancelled
Go / build (loong64, linux, linux-loong64) (push) Has been cancelled
Go / build (mips, linux, linux-mips) (push) Has been cancelled
Go / build (mips64, linux, linux-mips64) (push) Has been cancelled
Go / build (mips64le, linux, linux-mips64le) (push) Has been cancelled
Go / build (mipsle, linux, linux-mipsle) (push) Has been cancelled
Go / build (ppc64, aix, aix-ppc64) (push) Has been cancelled
Go / build (ppc64, linux, linux-ppc64) (push) Has been cancelled
Go / build (ppc64, openbsd, openbsd-ppc64) (push) Has been cancelled
Go / build (ppc64le, linux, linux-ppc64le) (push) Has been cancelled
Go / build (riscv64, freebsd, freebsd-riscv64) (push) Has been cancelled
Go / build (riscv64, linux, linux-riscv64) (push) Has been cancelled
Go / build (riscv64, openbsd, openbsd-riscv64) (push) Has been cancelled
Go / build (s390x, linux, linux-s390x) (push) Has been cancelled
Go / merge-artifacts (push) Has been cancelled
Docker Image / docker (push) Has been cancelled
2026-06-26 12:09:20 +08:00
tursom
b822993489 feat(plugin): add admin plugin management UI
Some checks failed
Go / build (.exe, 386, windows, windows-386) (push) Has been cancelled
Go / build (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Go / build (.exe, arm64, windows, windows-arm64) (push) Has been cancelled
Go / build (386, freebsd, freebsd-386) (push) Has been cancelled
Go / build (386, linux, linux-386) (push) Has been cancelled
Go / build (386, netbsd, netbsd-386) (push) Has been cancelled
Go / build (386, openbsd, openbsd-386) (push) Has been cancelled
Go / build (386, plan9, plan9-386) (push) Has been cancelled
Go / build (amd64, darwin, darwin-amd64) (push) Has been cancelled
Go / build (amd64, dragonfly, dragonfly-amd64) (push) Has been cancelled
Go / build (amd64, freebsd, freebsd-amd64) (push) Has been cancelled
Go / build (amd64, illumos, illumos-amd64) (push) Has been cancelled
Go / build (amd64, linux, linux-amd64) (push) Has been cancelled
Go / build (amd64, netbsd, netbsd-amd64) (push) Has been cancelled
Go / build (amd64, openbsd, openbsd-amd64) (push) Has been cancelled
Go / build (amd64, plan9, plan9-amd64) (push) Has been cancelled
Go / build (amd64, solaris, solaris-amd64) (push) Has been cancelled
Go / build (arm, 6, linux, linux-armv6) (push) Has been cancelled
Go / build (arm, 7, linux, linux-armv7) (push) Has been cancelled
Go / build (arm, freebsd, freebsd-arm) (push) Has been cancelled
Go / build (arm, netbsd, netbsd-arm) (push) Has been cancelled
Go / build (arm, openbsd, openbsd-arm) (push) Has been cancelled
Go / build (arm, plan9, plan9-arm) (push) Has been cancelled
Go / build (arm64, darwin, darwin-arm64) (push) Has been cancelled
Go / build (arm64, freebsd, freebsd-arm64) (push) Has been cancelled
Go / build (arm64, linux, linux-arm64) (push) Has been cancelled
Go / build (arm64, netbsd, netbsd-arm64) (push) Has been cancelled
Go / build (arm64, openbsd, openbsd-arm64) (push) Has been cancelled
Go / build (loong64, linux, linux-loong64) (push) Has been cancelled
Go / build (mips, linux, linux-mips) (push) Has been cancelled
Go / build (mips64, linux, linux-mips64) (push) Has been cancelled
Go / build (mips64le, linux, linux-mips64le) (push) Has been cancelled
Go / build (mipsle, linux, linux-mipsle) (push) Has been cancelled
Go / build (ppc64, aix, aix-ppc64) (push) Has been cancelled
Go / build (ppc64, linux, linux-ppc64) (push) Has been cancelled
Go / build (ppc64, openbsd, openbsd-ppc64) (push) Has been cancelled
Go / build (ppc64le, linux, linux-ppc64le) (push) Has been cancelled
Go / build (riscv64, freebsd, freebsd-riscv64) (push) Has been cancelled
Go / build (riscv64, linux, linux-riscv64) (push) Has been cancelled
Go / build (riscv64, openbsd, openbsd-riscv64) (push) Has been cancelled
Go / build (s390x, linux, linux-s390x) (push) Has been cancelled
Go / merge-artifacts (push) Has been cancelled
Docker Image / docker (push) Has been cancelled
2026-06-26 11:38:19 +08:00
tursom
eae2319328 feat(plugin): add source package builder
Some checks failed
Go / build (.exe, 386, windows, windows-386) (push) Has been cancelled
Go / build (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Go / build (.exe, arm64, windows, windows-arm64) (push) Has been cancelled
Go / build (386, freebsd, freebsd-386) (push) Has been cancelled
Go / build (386, linux, linux-386) (push) Has been cancelled
Go / build (386, netbsd, netbsd-386) (push) Has been cancelled
Go / build (386, openbsd, openbsd-386) (push) Has been cancelled
Go / build (386, plan9, plan9-386) (push) Has been cancelled
Go / build (amd64, darwin, darwin-amd64) (push) Has been cancelled
Go / build (amd64, dragonfly, dragonfly-amd64) (push) Has been cancelled
Go / build (amd64, freebsd, freebsd-amd64) (push) Has been cancelled
Go / build (amd64, illumos, illumos-amd64) (push) Has been cancelled
Go / build (amd64, linux, linux-amd64) (push) Has been cancelled
Go / build (amd64, netbsd, netbsd-amd64) (push) Has been cancelled
Go / build (amd64, openbsd, openbsd-amd64) (push) Has been cancelled
Go / build (amd64, plan9, plan9-amd64) (push) Has been cancelled
Go / build (amd64, solaris, solaris-amd64) (push) Has been cancelled
Go / build (arm, 6, linux, linux-armv6) (push) Has been cancelled
Go / build (arm, 7, linux, linux-armv7) (push) Has been cancelled
Go / build (arm, freebsd, freebsd-arm) (push) Has been cancelled
Go / build (arm, netbsd, netbsd-arm) (push) Has been cancelled
Go / build (arm, openbsd, openbsd-arm) (push) Has been cancelled
Go / build (arm, plan9, plan9-arm) (push) Has been cancelled
Go / build (arm64, darwin, darwin-arm64) (push) Has been cancelled
Go / build (arm64, freebsd, freebsd-arm64) (push) Has been cancelled
Go / build (arm64, linux, linux-arm64) (push) Has been cancelled
Go / build (arm64, netbsd, netbsd-arm64) (push) Has been cancelled
Go / build (arm64, openbsd, openbsd-arm64) (push) Has been cancelled
Go / build (loong64, linux, linux-loong64) (push) Has been cancelled
Go / build (mips, linux, linux-mips) (push) Has been cancelled
Go / build (mips64, linux, linux-mips64) (push) Has been cancelled
Go / build (mips64le, linux, linux-mips64le) (push) Has been cancelled
Go / build (mipsle, linux, linux-mipsle) (push) Has been cancelled
Go / build (ppc64, aix, aix-ppc64) (push) Has been cancelled
Go / build (ppc64, linux, linux-ppc64) (push) Has been cancelled
Go / build (ppc64, openbsd, openbsd-ppc64) (push) Has been cancelled
Go / build (ppc64le, linux, linux-ppc64le) (push) Has been cancelled
Go / build (riscv64, freebsd, freebsd-riscv64) (push) Has been cancelled
Go / build (riscv64, linux, linux-riscv64) (push) Has been cancelled
Go / build (riscv64, openbsd, openbsd-riscv64) (push) Has been cancelled
Go / build (s390x, linux, linux-s390x) (push) Has been cancelled
Go / merge-artifacts (push) Has been cancelled
Docker Image / docker (push) Has been cancelled
2026-06-26 10:53:41 +08:00
tursom
ae8706f8c8 feat(plugin): add protocol proxy mode
Some checks failed
Go / build (.exe, 386, windows, windows-386) (push) Has been cancelled
Go / build (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Go / build (.exe, arm64, windows, windows-arm64) (push) Has been cancelled
Go / build (386, freebsd, freebsd-386) (push) Has been cancelled
Go / build (386, linux, linux-386) (push) Has been cancelled
Go / build (386, netbsd, netbsd-386) (push) Has been cancelled
Go / build (386, openbsd, openbsd-386) (push) Has been cancelled
Go / build (386, plan9, plan9-386) (push) Has been cancelled
Go / build (amd64, darwin, darwin-amd64) (push) Has been cancelled
Go / build (amd64, dragonfly, dragonfly-amd64) (push) Has been cancelled
Go / build (amd64, freebsd, freebsd-amd64) (push) Has been cancelled
Go / build (amd64, illumos, illumos-amd64) (push) Has been cancelled
Go / build (amd64, linux, linux-amd64) (push) Has been cancelled
Go / build (amd64, netbsd, netbsd-amd64) (push) Has been cancelled
Go / build (amd64, openbsd, openbsd-amd64) (push) Has been cancelled
Go / build (amd64, plan9, plan9-amd64) (push) Has been cancelled
Go / build (amd64, solaris, solaris-amd64) (push) Has been cancelled
Go / build (arm, 6, linux, linux-armv6) (push) Has been cancelled
Go / build (arm, 7, linux, linux-armv7) (push) Has been cancelled
Go / build (arm, freebsd, freebsd-arm) (push) Has been cancelled
Go / build (arm, netbsd, netbsd-arm) (push) Has been cancelled
Go / build (arm, openbsd, openbsd-arm) (push) Has been cancelled
Go / build (arm, plan9, plan9-arm) (push) Has been cancelled
Go / build (arm64, darwin, darwin-arm64) (push) Has been cancelled
Go / build (arm64, freebsd, freebsd-arm64) (push) Has been cancelled
Go / build (arm64, linux, linux-arm64) (push) Has been cancelled
Go / build (arm64, netbsd, netbsd-arm64) (push) Has been cancelled
Go / build (arm64, openbsd, openbsd-arm64) (push) Has been cancelled
Go / build (loong64, linux, linux-loong64) (push) Has been cancelled
Go / build (mips, linux, linux-mips) (push) Has been cancelled
Go / build (mips64, linux, linux-mips64) (push) Has been cancelled
Go / build (mips64le, linux, linux-mips64le) (push) Has been cancelled
Go / build (mipsle, linux, linux-mipsle) (push) Has been cancelled
Go / build (ppc64, aix, aix-ppc64) (push) Has been cancelled
Go / build (ppc64, linux, linux-ppc64) (push) Has been cancelled
Go / build (ppc64, openbsd, openbsd-ppc64) (push) Has been cancelled
Go / build (ppc64le, linux, linux-ppc64le) (push) Has been cancelled
Go / build (riscv64, freebsd, freebsd-riscv64) (push) Has been cancelled
Go / build (riscv64, linux, linux-riscv64) (push) Has been cancelled
Go / build (riscv64, openbsd, openbsd-riscv64) (push) Has been cancelled
Go / build (s390x, linux, linux-s390x) (push) Has been cancelled
Go / merge-artifacts (push) Has been cancelled
Docker Image / docker (push) Has been cancelled
2026-06-26 10:13:00 +08:00
tursom
f4a11fb770 feat(plugin): add managed binary plugin MVP
Some checks failed
Go / build (.exe, 386, windows, windows-386) (push) Has been cancelled
Go / build (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Go / build (.exe, arm64, windows, windows-arm64) (push) Has been cancelled
Go / build (386, freebsd, freebsd-386) (push) Has been cancelled
Go / build (386, linux, linux-386) (push) Has been cancelled
Go / build (386, netbsd, netbsd-386) (push) Has been cancelled
Go / build (386, openbsd, openbsd-386) (push) Has been cancelled
Go / build (386, plan9, plan9-386) (push) Has been cancelled
Go / build (amd64, darwin, darwin-amd64) (push) Has been cancelled
Go / build (amd64, dragonfly, dragonfly-amd64) (push) Has been cancelled
Go / build (amd64, freebsd, freebsd-amd64) (push) Has been cancelled
Go / build (amd64, illumos, illumos-amd64) (push) Has been cancelled
Go / build (amd64, linux, linux-amd64) (push) Has been cancelled
Go / build (amd64, netbsd, netbsd-amd64) (push) Has been cancelled
Go / build (amd64, openbsd, openbsd-amd64) (push) Has been cancelled
Go / build (amd64, plan9, plan9-amd64) (push) Has been cancelled
Go / build (amd64, solaris, solaris-amd64) (push) Has been cancelled
Go / build (arm, 6, linux, linux-armv6) (push) Has been cancelled
Go / build (arm, 7, linux, linux-armv7) (push) Has been cancelled
Go / build (arm, freebsd, freebsd-arm) (push) Has been cancelled
Go / build (arm, netbsd, netbsd-arm) (push) Has been cancelled
Go / build (arm, openbsd, openbsd-arm) (push) Has been cancelled
Go / build (arm, plan9, plan9-arm) (push) Has been cancelled
Go / build (arm64, darwin, darwin-arm64) (push) Has been cancelled
Go / build (arm64, freebsd, freebsd-arm64) (push) Has been cancelled
Go / build (arm64, linux, linux-arm64) (push) Has been cancelled
Go / build (arm64, netbsd, netbsd-arm64) (push) Has been cancelled
Go / build (arm64, openbsd, openbsd-arm64) (push) Has been cancelled
Go / build (loong64, linux, linux-loong64) (push) Has been cancelled
Go / build (mips, linux, linux-mips) (push) Has been cancelled
Go / build (mips64, linux, linux-mips64) (push) Has been cancelled
Go / build (mips64le, linux, linux-mips64le) (push) Has been cancelled
Go / build (mipsle, linux, linux-mipsle) (push) Has been cancelled
Go / build (ppc64, aix, aix-ppc64) (push) Has been cancelled
Go / build (ppc64, linux, linux-ppc64) (push) Has been cancelled
Go / build (ppc64, openbsd, openbsd-ppc64) (push) Has been cancelled
Go / build (ppc64le, linux, linux-ppc64le) (push) Has been cancelled
Go / build (riscv64, freebsd, freebsd-riscv64) (push) Has been cancelled
Go / build (riscv64, linux, linux-riscv64) (push) Has been cancelled
Go / build (riscv64, openbsd, openbsd-riscv64) (push) Has been cancelled
Go / build (s390x, linux, linux-s390x) (push) Has been cancelled
Go / merge-artifacts (push) Has been cancelled
Docker Image / docker (push) Has been cancelled
2026-06-26 09:41:30 +08:00
tursom
c21edfdf1a docs: add plugin system design plan 2026-06-26 08:59:59 +08:00
67 changed files with 24798 additions and 143 deletions

View File

@@ -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,
})
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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 = {};

View File

@@ -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;

View File

@@ -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();

View File

@@ -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 {

View File

@@ -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;
}

View 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;
}

View 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 ?? "");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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() {

View File

@@ -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;

View File

@@ -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>

View File

@@ -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)()

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
View 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")
}

View File

@@ -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 {

View 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 默认不进入指标标签、审计明文或普通诊断输出。
## 阶段推进规则
进入下一阶段前必须满足:
- 当前阶段文档中的验收项全部通过。
- 已实现能力有最小自动化测试或可重复手动验证步骤。
- 失败路径已验证:加载失败、启用失败、禁用、删除、重启恢复。
- 文档已更新:用户怎么启用、怎么回滚、怎么排障。
如果某阶段出现实现复杂度超出预期,允许拆出子阶段,但子阶段也必须保持“当前可用”。

View File

@@ -0,0 +1,125 @@
# 阶段 1Managed 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 stateenabled、disabled、deleted。
- runtime statenot_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 应可在禁用插件系统配置下启动,并保留原有路由能力。

View File

@@ -0,0 +1,108 @@
# 阶段 2Protocol 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 插件能力。

View File

@@ -0,0 +1,112 @@
# 阶段 3Source 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`

View File

@@ -0,0 +1,110 @@
# 阶段 4Admin 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。

View File

@@ -0,0 +1,116 @@
# 阶段 5Governance 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。

View File

@@ -0,0 +1,123 @@
# 阶段 6Observability 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清理操作写审计。

View File

@@ -0,0 +1,116 @@
# 阶段 7Extension 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 修复或禁用。

View File

@@ -0,0 +1,180 @@
# 阶段 8Future 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 策略能参与准入结果。
## 子阶段 Ago-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 主进程。
## 子阶段 Bsandbox-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 不通过长期环境变量注入。
## 子阶段 CWASM
实现:
- WASM host ABI。
- route/rule/config validate extension point。
- memory/time limits。
- no file/no network 默认策略。
验收:
- WASM rule 插件可以返回 allow/deny/rewrite。
- 超时或内存超限只影响当前调用。
- WASM 插件不能访问未授权 secret/network。
## 子阶段 Dingress service
实现:
- `ingress.service/v1` schema。
- service supervisor。
- listener ownership by gateway。
- port conflict check。
- TLS/secret refs。
- health and drain。
验收:
- 插件声明入口服务后,由 gateway 创建 listener。
- disable 后停止接收新连接并 drain。
- 端口冲突阻断启用。
## 子阶段 Erepository 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。
## 子阶段 Fbuild-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

File diff suppressed because it is too large Load Diff

View 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"
}
```

View 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
)

View 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)
}
}

View 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 => ../../..

View 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)
}

View 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...)
}

View 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" }
}
}
}

View 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"
}
```

View 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
)

View File

@@ -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)
}
}

View 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 => ../../..

View 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)
}

View 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"]
}
}

View File

@@ -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

View File

@@ -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, &notNull, &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
}

View File

@@ -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")
}

View File

@@ -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()

View File

@@ -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"`
}

View File

@@ -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),
}

View File

@@ -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,
}

View 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()
}

View 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
}

View 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()
}

View 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)
}

File diff suppressed because it is too large Load Diff

View 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)
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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
}

View File

@@ -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{}

View File

@@ -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 }

View File

@@ -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),

View File

@@ -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
}
}
}

View File

@@ -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
View 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
}