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

This commit is contained in:
2026-06-26 09:38:10 +08:00
parent c21edfdf1a
commit f4a11fb770
27 changed files with 2952 additions and 25 deletions

View File

@@ -28,5 +28,12 @@ func newAdminAPIHandler() http.HandlerFunc {
UserItem: handleAdminUserItem,
AuditLogs: handleAdminAuditLogs,
PluginArtifacts: handleAdminPluginArtifacts,
PluginArtifact: handleAdminPluginArtifact,
PluginsList: handleAdminPluginsList,
PluginItem: handleAdminPluginItem,
PluginAction: handleAdminPluginAction,
PluginDispatch: handleAdminPluginDispatchPlan,
})
}

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

@@ -0,0 +1,275 @@
package main
import (
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/tursom/mc-gateway/internal/adminhttp"
"github.com/tursom/mc-gateway/internal/pluginmanager"
)
func handleAdminPluginArtifacts(w http.ResponseWriter, r *http.Request) {
session, ok := requireRole(w, r, adminRoleMember)
if !ok {
return
}
if pluginsManager == nil {
adminhttp.WriteAPIError(w, http.StatusServiceUnavailable, "plugin manager is not initialized")
return
}
switch r.Method {
case http.MethodGet:
artifacts, err := pluginsManager.ListArtifacts(r.Context(), r.URL.Query().Get("plugin_id"))
if err != nil {
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
return
}
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"artifacts": artifacts})
case http.MethodPost:
if session.Role != adminRoleAdmin {
adminhttp.WriteAPIError(w, http.StatusForbidden, "forbidden")
return
}
artifact, err := receivePluginArtifact(r, session.Username)
if err != nil {
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_artifact_upload", "plugin_artifact", "", false, err.Error())
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
return
}
recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_artifact_upload", "plugin_artifact", artifact.ID, true, "artifact uploaded", map[string]any{
"plugin_id": artifact.PluginID,
"version": artifact.Version,
"sha256": artifact.SHA256,
"package_sha256": artifact.PackageSHA256,
})
adminhttp.WriteJSON(w, http.StatusCreated, map[string]any{"artifact": artifact})
default:
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func handleAdminPluginArtifact(w http.ResponseWriter, r *http.Request, rawArtifactID string) {
if _, ok := requireRole(w, r, adminRoleMember); !ok {
return
}
if pluginsManager == nil {
adminhttp.WriteAPIError(w, http.StatusServiceUnavailable, "plugin manager is not initialized")
return
}
artifactID, err := adminhttp.PathSegment(rawArtifactID)
if err != nil {
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
return
}
if r.Method != http.MethodGet {
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
artifact, err := pluginsManager.Artifact(r.Context(), artifactID)
if err != nil {
writePluginManagerError(w, err)
return
}
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"artifact": artifact})
}
func handleAdminPluginsList(w http.ResponseWriter, r *http.Request) {
if _, ok := requireRole(w, r, adminRoleMember); !ok {
return
}
if pluginsManager == nil {
adminhttp.WriteAPIError(w, http.StatusServiceUnavailable, "plugin manager is not initialized")
return
}
plugins, err := pluginsManager.ListPlugins(r.Context())
if err != nil {
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
return
}
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"plugins": plugins})
}
func handleAdminPluginItem(w http.ResponseWriter, r *http.Request, rawPluginID string) {
session, ok := requireRole(w, r, adminRoleMember)
if !ok {
return
}
if pluginsManager == nil {
adminhttp.WriteAPIError(w, http.StatusServiceUnavailable, "plugin manager is not initialized")
return
}
pluginID, err := adminhttp.PathSegment(rawPluginID)
if err != nil {
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
return
}
switch r.Method {
case http.MethodGet:
plugin, err := pluginsManager.Plugin(r.Context(), pluginID)
if err != nil {
writePluginManagerError(w, err)
return
}
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"plugin": plugin})
case http.MethodPut:
if session.Role != adminRoleAdmin {
adminhttp.WriteAPIError(w, http.StatusForbidden, "forbidden")
return
}
var req adminhttp.PluginDesiredRequest
if !adminhttp.DecodeJSONRequest(w, r, &req) {
return
}
configJSON, err := pluginConfigJSON(req)
if err != nil {
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
return
}
plugin, err := pluginsManager.SetDesired(r.Context(), session.Username, pluginID, req.ArtifactID, req.DesiredState, configJSON, req.Priority)
if err != nil {
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_desired_update", "plugin", pluginID, false, err.Error())
writePluginManagerError(w, err)
return
}
recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_desired_update", "plugin", pluginID, true, "desired state updated", map[string]any{
"artifact_id": req.ArtifactID,
"desired_state": plugin.DesiredState,
"desired_generation": plugin.DesiredGeneration,
"priority": plugin.Priority,
})
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"plugin": plugin})
default:
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func handleAdminPluginAction(w http.ResponseWriter, r *http.Request, rawSegment string) {
session, ok := requireRole(w, r, adminRoleAdmin)
if !ok {
return
}
if pluginsManager == nil {
adminhttp.WriteAPIError(w, http.StatusServiceUnavailable, "plugin manager is not initialized")
return
}
if r.Method != http.MethodPost {
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
parts := strings.Split(rawSegment, "/")
if len(parts) != 2 {
adminhttp.WriteAPIError(w, http.StatusBadRequest, "invalid plugin action")
return
}
pluginID, err := adminhttp.PathSegment(parts[0])
if err != nil {
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
return
}
action, err := adminhttp.PathSegment(parts[1])
if err != nil {
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
return
}
var plugin pluginmanager.PluginRecord
switch action {
case "load":
plugin, err = pluginsManager.Load(r.Context(), session.Username, pluginID)
case "enable":
plugin, err = pluginsManager.Enable(r.Context(), session.Username, pluginID)
case "disable":
plugin, err = pluginsManager.Disable(r.Context(), session.Username, pluginID)
case "delete":
err = pluginsManager.Delete(r.Context(), session.Username, pluginID)
default:
adminhttp.WriteAPIError(w, http.StatusBadRequest, "unknown plugin action")
return
}
if err != nil {
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_"+action, "plugin", pluginID, false, err.Error())
writePluginManagerError(w, err)
return
}
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_"+action, "plugin", pluginID, true, "plugin "+action+" succeeded")
if action == "delete" {
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
return
}
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"plugin": plugin})
}
func handleAdminPluginDispatchPlan(w http.ResponseWriter, r *http.Request) {
if _, ok := requireRole(w, r, adminRoleMember); !ok {
return
}
if pluginsManager == nil {
adminhttp.WriteAPIError(w, http.StatusServiceUnavailable, "plugin manager is not initialized")
return
}
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"dispatch_plan": pluginsManager.DispatchPlan(r.Context())})
}
func receivePluginArtifact(r *http.Request, actor string) (pluginmanager.ArtifactRecord, error) {
if err := r.ParseMultipartForm(64 << 20); err != nil {
return pluginmanager.ArtifactRecord{}, err
}
file, header, err := r.FormFile("artifact")
if err != nil {
return pluginmanager.ArtifactRecord{}, err
}
defer file.Close()
tmp, err := os.CreateTemp("", "mc-gateway-plugin-*.mcgp")
if err != nil {
return pluginmanager.ArtifactRecord{}, err
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
defer tmp.Close()
if _, err := tmp.ReadFrom(file); err != nil {
return pluginmanager.ArtifactRecord{}, err
}
if err := tmp.Close(); err != nil {
return pluginmanager.ArtifactRecord{}, err
}
return pluginsManager.UploadArtifact(r.Context(), pluginmanager.ArtifactUpload{
SourcePath: tmpPath,
FileName: filepath.Base(header.Filename),
Actor: actor,
})
}
func pluginConfigJSON(req adminhttp.PluginDesiredRequest) (string, error) {
if strings.TrimSpace(req.ConfigJSON) != "" {
if !json.Valid([]byte(req.ConfigJSON)) {
return "", errors.New("config_json must be valid JSON")
}
return req.ConfigJSON, nil
}
if req.Config == nil {
return "{}", nil
}
data, err := json.Marshal(req.Config)
if err != nil {
return "", err
}
return string(data), nil
}
func writePluginManagerError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, pluginmanager.ErrArtifactNotFound), errors.Is(err, pluginmanager.ErrPluginNotFound):
adminhttp.WriteAPIError(w, http.StatusNotFound, err.Error())
default:
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
}
}

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

@@ -1,7 +1,9 @@
package main
import (
"context"
"net"
"os"
"sync"
"github.com/rs/zerolog/log"
@@ -11,6 +13,10 @@ import (
)
func main() {
if handled, code := runPluginCLI(os.Args[1:]); handled {
os.Exit(code)
}
if err := loadConfig(); err != nil {
panic(err)
}
@@ -127,17 +133,39 @@ func mapToHost(conn net.Conn) net.Conn {
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 {
result, err := pluginsManager.ConnectUpstream(context.Background(), api.UpstreamConnectRequest{
Source: conn,
Host: mcHost,
Upstream: host,
InitialData: append([]byte(nil), buf[:n]...),
})
if err != nil {
log.Err(err).
Str("client", conn.RemoteAddr().String()).
Str("host", mcHost).
Str("mc", host).
Msg("failed to invoke managed upstream plugin")
return nil
}
if result.Handled {
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:

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,110 @@ 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()
artifact, err := manager.UploadArtifact(context.Background(), pluginmanager.ArtifactUpload{
SourcePath: writeGatewayTestMCGP(t, pluginID),
FileName: pluginID + ".mcgp",
Actor: "admin",
})
if err != nil {
t.Fatalf("UploadArtifact() error = %v", err)
}
return artifact
}
func writeGatewayTestMCGP(t *testing.T, pluginID string) 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)
entries := map[string][]byte{
"manifest.json": gatewayTestManifest(t, pluginID),
"plugin.so": []byte("fake plugin bytes " + pluginID),
}
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 {
t.Helper()
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,
}},
}
data, err := json.Marshal(manifest)
if err != nil {
t.Fatalf("Marshal manifest error = %v", err)
}
return data
}

94
cmd/gateway/plugin_cli.go Normal file
View File

@@ -0,0 +1,94 @@
package main
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"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 <artifact.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
default:
fmt.Fprintf(os.Stderr, "unknown plugin command %q\n", command)
return true, 2
}
}
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

View File

@@ -0,0 +1,23 @@
# 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 package is written to `dist/upstream-rewrite.mcgp`.
Example config JSON:
```json
{
"match_host": "play.example",
"upstream": "127.0.0.1:25566"
}
```

View File

@@ -0,0 +1,12 @@
#!/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
)

View File

@@ -0,0 +1,55 @@
package main
import (
"encoding/json"
"os"
"runtime"
)
func main() {
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": "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",
"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"},
},
}
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,119 @@ 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_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 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_config_snapshots_plugin_id ON plugin_config_snapshots(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
}
return ensureColumn(db, "audit_logs", "metadata_json", "TEXT NOT NULL DEFAULT '{}'")
}
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,13 @@ type APIHandlers struct {
UserItem SegmentHandlerFunc
AuditLogs http.HandlerFunc
PluginArtifacts http.HandlerFunc
PluginArtifact SegmentHandlerFunc
PluginsList http.HandlerFunc
PluginItem SegmentHandlerFunc
PluginAction SegmentHandlerFunc
PluginDispatch http.HandlerFunc
}
func NewAPIHandler(prefix string, handlers APIHandlers) http.HandlerFunc {
@@ -69,6 +76,21 @@ 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 == "/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 strings.HasPrefix(path, "/plugins/"):
pluginPath := strings.TrimPrefix(path, "/plugins/")
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,12 @@ 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: "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 dispatch", method: http.MethodGet, path: "/admin/api/plugins/dispatch-plan", wantCall: "plugin_dispatch"},
}
for _, tt := range tests {
@@ -56,6 +62,13 @@ 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"),
PluginsList: recordCall(&gotCall, "plugins_list"),
PluginItem: recordSegmentCall(&gotCall, &gotSegment, "plugin_item"),
PluginAction: recordSegmentCall(&gotCall, &gotSegment, "plugin_action"),
PluginDispatch: recordCall(&gotCall, "plugin_dispatch"),
})
resp := httptest.NewRecorder()

View File

@@ -34,3 +34,11 @@ 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"`
}

View File

@@ -0,0 +1,311 @@
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}$`)
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) {
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 {
clean, err := cleanZipName(file.Name)
if err != nil {
return ArtifactRecord{}, err
}
if file.FileInfo().IsDir() {
continue
}
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
}
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 := manifest.Capabilities
if len(capabilities) == 0 {
capabilities = json.RawMessage(`{}`)
}
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 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:
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.Runtime.Entry != RuntimeEntry:
return fmt.Errorf("unsupported runtime.entry %q", manifest.Runtime.Entry)
case manifest.APIVersion != APIVersion:
return fmt.Errorf("unsupported api_version %q", manifest.APIVersion)
case manifest.GoVersion == "":
return errors.New("go_version is required")
case manifest.GOOS == "":
return errors.New("go_os is required")
case manifest.GOARCH == "":
return errors.New("go_arch is required")
}
if manifest.GOOS != runtime.GOOS {
return fmt.Errorf("go_os %q does not match gateway %q", manifest.GOOS, runtime.GOOS)
}
if 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)
}
return nil
}
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 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
}

View File

@@ -0,0 +1,149 @@
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 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 testManifestBytes(t *testing.T, pluginID string) []byte {
t.Helper()
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: json.RawMessage(`{"extension_points":["upstream.connect/v1"]}`),
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,555 @@
package pluginmanager
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net"
stdplugin "plugin"
"reflect"
"sort"
"sync"
"sync/atomic"
"time"
"github.com/tursom/mc-gateway/plugin/api"
)
type RuntimeAdapter interface {
Load(ctx context.Context, artifact ArtifactRecord, pluginRecord PluginRecord, gateway *Gateway) (api.Plugin, error)
}
type GoPluginAdapter struct{}
func (a GoPluginAdapter) Load(ctx context.Context, artifact ArtifactRecord, pluginRecord PluginRecord, gateway *Gateway) (api.Plugin, error) {
_ = ctx
opened, err := stdplugin.Open(artifact.FilePath)
if err != nil {
return nil, err
}
symbolName := "Plugin"
var manifest Manifest
if err := json.Unmarshal([]byte(artifact.MetadataJSON), &manifest); err == nil && manifest.Runtime.EntrySymbol != "" {
symbolName = manifest.Runtime.EntrySymbol
}
symbol, err := opened.Lookup(symbolName)
if err != nil {
return nil, err
}
factory, ok := symbol.(func() api.Plugin)
if !ok {
return nil, fmt.Errorf("plugin symbol %q has invalid signature", symbolName)
}
instance := factory()
cfg := instance.NewConfigObj()
if cfg != nil && pluginRecord.ConfigJSON != "" && canUnmarshalInto(cfg) {
if err := json.Unmarshal([]byte(pluginRecord.ConfigJSON), cfg); err != nil {
return nil, fmt.Errorf("decode plugin config: %w", err)
}
}
if err := instance.ReloadConfig(cfg); err != nil {
return nil, err
}
if err := instance.Init(gateway); err != nil {
return nil, err
}
return instance, nil
}
func canUnmarshalInto(value any) bool {
if value == nil {
return false
}
kind := reflect.TypeOf(value).Kind()
return kind == reflect.Pointer || kind == reflect.Map || kind == reflect.Slice
}
type Manager struct {
repo Repository
store ArtifactStore
adapter RuntimeAdapter
handleConn func(net.Conn)
wg *sync.WaitGroup
mu sync.Mutex
loaded map[string]*loadedPlugin
snapshot atomic.Value
}
type loadedPlugin struct {
record PluginRecord
artifact ArtifactRecord
instance api.Plugin
gateway *Gateway
handlers []*upstreamHandler
}
type upstreamHandler struct {
pluginID string
artifactID string
priority int
handlerID string
timeout time.Duration
accept func(api.UpstreamConnectRequest) bool
handle func(api.UpstreamConnectRequest) (net.Conn, error)
calls atomic.Uint64
errors atomic.Uint64
panics atomic.Uint64
timeouts atomic.Uint64
}
type Options struct {
DB *sql.DB
ArtifactRoot string
HandleConn func(net.Conn)
WaitGroup *sync.WaitGroup
Adapter RuntimeAdapter
}
func New(options Options) *Manager {
adapter := options.Adapter
if adapter == nil {
adapter = GoPluginAdapter{}
}
manager := &Manager{
repo: NewRepository(options.DB),
store: NewArtifactStore(options.ArtifactRoot),
adapter: adapter,
handleConn: options.HandleConn,
wg: options.WaitGroup,
loaded: make(map[string]*loadedPlugin),
}
manager.publish(nil)
return manager
}
func (m *Manager) UploadArtifact(ctx context.Context, upload ArtifactUpload) (ArtifactRecord, error) {
artifact, err := m.store.ValidateAndStore(upload)
if err != nil {
_ = m.repo.RecordOperation(ctx, "", "", "artifact_upload", "failed", upload.Actor, err.Error(), nil)
return ArtifactRecord{}, err
}
if err := m.repo.SaveArtifact(ctx, artifact); err != nil {
return ArtifactRecord{}, err
}
_ = m.repo.RecordOperation(ctx, artifact.PluginID, artifact.ID, "artifact_upload", "succeeded", upload.Actor, "artifact uploaded", map[string]any{
"sha256": artifact.SHA256,
"package_sha256": artifact.PackageSHA256,
"api_version": artifact.APIVersion,
"extension_points": artifact.ExtensionPointsJSON,
})
return artifact, nil
}
func (m *Manager) SetDesired(ctx context.Context, actor, pluginID, artifactID, desiredState, configJSON string, priority int) (PluginRecord, error) {
pluginRecord, err := m.repo.UpsertDesired(ctx, actor, pluginID, artifactID, desiredState, configJSON, priority)
if err != nil {
_ = m.repo.RecordOperation(ctx, pluginID, artifactID, "desired_update", "failed", actor, err.Error(), nil)
return PluginRecord{}, err
}
_ = m.repo.RecordOperation(ctx, pluginID, artifactID, "desired_update", "succeeded", actor, "desired state updated", map[string]any{
"desired_state": desiredState,
"desired_generation": pluginRecord.DesiredGeneration,
"priority": pluginRecord.Priority,
})
return pluginRecord, nil
}
func (m *Manager) Load(ctx context.Context, actor, pluginID string) (PluginRecord, error) {
m.mu.Lock()
defer m.mu.Unlock()
pluginRecord, err := m.repo.Plugin(ctx, pluginID)
if err != nil {
return PluginRecord{}, err
}
loaded, err := m.loadLocked(ctx, pluginRecord)
if err != nil {
_ = m.repo.RecordOperation(ctx, pluginID, pluginRecord.DesiredArtifactID, "load", "failed", actor, err.Error(), nil)
return PluginRecord{}, err
}
_ = m.repo.RecordOperation(ctx, pluginID, loaded.artifact.ID, "load", "succeeded", actor, "plugin loaded", nil)
return m.repo.Plugin(ctx, pluginID)
}
func (m *Manager) Enable(ctx context.Context, actor, pluginID string) (PluginRecord, error) {
m.mu.Lock()
defer m.mu.Unlock()
pluginRecord, err := m.repo.Plugin(ctx, pluginID)
if err != nil {
return PluginRecord{}, err
}
if pluginRecord.DesiredState != DesiredEnabled {
pluginRecord, err = m.repo.UpsertDesired(ctx, actor, pluginRecord.ID, pluginRecord.DesiredArtifactID, DesiredEnabled, pluginRecord.ConfigJSON, pluginRecord.Priority)
if err != nil {
return PluginRecord{}, err
}
}
loaded, err := m.loadLocked(ctx, pluginRecord)
if err != nil {
_ = m.repo.RecordOperation(ctx, pluginID, pluginRecord.DesiredArtifactID, "enable", "failed", actor, err.Error(), nil)
return PluginRecord{}, err
}
if len(loaded.handlers) == 0 {
err := fmt.Errorf("plugin %q did not register %s", pluginID, ExtensionUpstreamConnect)
_ = m.repo.MarkRuntime(ctx, pluginID, RuntimeFailed, "", loaded.artifact.ID, pluginRecord.AppliedGeneration, err.Error(), map[string]any{"error": err.Error()}, nil)
_ = m.repo.RecordOperation(ctx, pluginID, pluginRecord.DesiredArtifactID, "enable", "failed", actor, err.Error(), nil)
return PluginRecord{}, err
}
current := m.currentHandlersLocked()
current[pluginID] = loaded.handlers
next := flattenHandlers(current)
if err := m.markEnabled(ctx, loaded); err != nil {
return PluginRecord{}, err
}
m.publish(next)
_ = m.repo.UpdateArtifactStatus(ctx, loaded.artifact.ID, ArtifactStatusLoaded, "")
_ = m.repo.RecordOperation(ctx, pluginID, loaded.artifact.ID, "enable", "succeeded", actor, "plugin enabled", map[string]any{
"desired_generation": loaded.record.DesiredGeneration,
"handler_count": len(loaded.handlers),
})
return m.repo.Plugin(ctx, pluginID)
}
func (m *Manager) Disable(ctx context.Context, actor, pluginID string) (PluginRecord, error) {
m.mu.Lock()
defer m.mu.Unlock()
pluginRecord, err := m.repo.Plugin(ctx, pluginID)
if err != nil {
return PluginRecord{}, err
}
pluginRecord, err = m.repo.UpsertDesired(ctx, actor, pluginRecord.ID, pluginRecord.DesiredArtifactID, DesiredDisabled, pluginRecord.ConfigJSON, pluginRecord.Priority)
if err != nil {
return PluginRecord{}, err
}
m.removeFromDispatchLocked(pluginID)
if loaded := m.loaded[pluginID]; loaded != nil && loaded.instance != nil {
if err := loaded.instance.Destroy(); err != nil {
_ = m.repo.RecordOperation(ctx, pluginID, loaded.artifact.ID, "disable", "warning", actor, err.Error(), nil)
}
}
delete(m.loaded, pluginID)
if err := m.repo.MarkRuntime(ctx, pluginID, RuntimeDisabled, "", "", pluginRecord.DesiredGeneration, "", nil, nil); err != nil {
return PluginRecord{}, err
}
_ = m.repo.RecordOperation(ctx, pluginID, pluginRecord.DesiredArtifactID, "disable", "succeeded", actor, "plugin disabled", nil)
return m.repo.Plugin(ctx, pluginID)
}
func (m *Manager) Delete(ctx context.Context, actor, pluginID string) error {
m.mu.Lock()
defer m.mu.Unlock()
pluginRecord, err := m.repo.Plugin(ctx, pluginID)
if err != nil {
return err
}
m.removeFromDispatchLocked(pluginID)
if loaded := m.loaded[pluginID]; loaded != nil && loaded.instance != nil {
_ = loaded.instance.Destroy()
}
delete(m.loaded, pluginID)
if _, err := m.repo.UpsertDesired(ctx, actor, pluginRecord.ID, pluginRecord.DesiredArtifactID, DesiredDeleted, pluginRecord.ConfigJSON, pluginRecord.Priority); err != nil {
return err
}
_ = m.repo.RecordOperation(ctx, pluginID, pluginRecord.DesiredArtifactID, "delete", "succeeded", actor, "plugin deleted", map[string]any{
"cleanup": "pending_restart_for_loaded_go_plugin",
})
return nil
}
func (m *Manager) Reconcile(ctx context.Context) error {
m.mu.Lock()
defer m.mu.Unlock()
desired, err := m.repo.DesiredEnabled(ctx)
if err != nil {
return err
}
nextByPlugin := make(map[string][]*upstreamHandler)
for _, pluginRecord := range desired {
loaded, err := m.loadLocked(ctx, pluginRecord)
if err != nil {
_ = m.repo.MarkRuntime(ctx, pluginRecord.ID, RuntimeFailed, "", "", pluginRecord.AppliedGeneration, err.Error(), map[string]any{"error": err.Error()}, nil)
_ = m.repo.RecordOperation(ctx, pluginRecord.ID, pluginRecord.DesiredArtifactID, "reconcile", "failed", "system", err.Error(), nil)
continue
}
if len(loaded.handlers) == 0 {
err := fmt.Errorf("plugin %q did not register %s", pluginRecord.ID, ExtensionUpstreamConnect)
_ = m.repo.MarkRuntime(ctx, pluginRecord.ID, RuntimeFailed, "", loaded.artifact.ID, pluginRecord.AppliedGeneration, err.Error(), map[string]any{"error": err.Error()}, nil)
_ = m.repo.RecordOperation(ctx, pluginRecord.ID, pluginRecord.DesiredArtifactID, "reconcile", "failed", "system", err.Error(), nil)
continue
}
nextByPlugin[pluginRecord.ID] = loaded.handlers
_ = m.markEnabled(ctx, loaded)
}
m.publish(flattenHandlers(nextByPlugin))
return nil
}
func (m *Manager) ConnectUpstream(ctx context.Context, req api.UpstreamConnectRequest) (UpstreamResult, error) {
value := m.snapshot.Load()
if value == nil {
return UpstreamResult{}, nil
}
handlers, ok := value.([]*upstreamHandler)
if !ok {
return UpstreamResult{}, nil
}
if req.Context == nil {
req.Context = ctx
}
for _, handler := range handlers {
accepted, err := handler.accepts(req)
if err != nil {
return UpstreamResult{Handled: true}, err
}
if !accepted {
continue
}
conn, err := handler.invoke(req)
if errors.Is(err, api.ErrPass) {
continue
}
if err != nil {
return UpstreamResult{Handled: true}, err
}
if conn != nil {
return UpstreamResult{Conn: conn, Handled: true}, nil
}
}
return UpstreamResult{}, nil
}
func (h *upstreamHandler) accepts(req api.UpstreamConnectRequest) (accepted bool, err error) {
if h.accept == nil {
return true, nil
}
defer func() {
if rec := recover(); rec != nil {
h.panics.Add(1)
accepted = false
err = fmt.Errorf("plugin %s acceptor panic: %v", h.pluginID, rec)
}
}()
return h.accept(req), nil
}
func (m *Manager) ListArtifacts(ctx context.Context, pluginID string) ([]ArtifactRecord, error) {
return m.repo.ListArtifacts(ctx, pluginID)
}
func (m *Manager) Artifact(ctx context.Context, id string) (ArtifactRecord, error) {
return m.repo.Artifact(ctx, id)
}
func (m *Manager) ListPlugins(ctx context.Context) ([]PluginRecord, error) {
return m.repo.ListPlugins(ctx)
}
func (m *Manager) Plugin(ctx context.Context, id string) (PluginRecord, error) {
return m.repo.Plugin(ctx, id)
}
func (m *Manager) DispatchPlan(ctx context.Context) DispatchPlan {
value := m.snapshot.Load()
plan := DispatchPlan{UpdatedAt: time.Now().Unix()}
if handlers, ok := value.([]*upstreamHandler); ok {
plan.Handlers = handlerSummaries(handlers)
}
return plan
}
func (m *Manager) loadLocked(ctx context.Context, pluginRecord PluginRecord) (*loadedPlugin, error) {
if loaded := m.loaded[pluginRecord.ID]; loaded != nil &&
loaded.artifact.ID == pluginRecord.DesiredArtifactID &&
loaded.record.DesiredGeneration == pluginRecord.DesiredGeneration {
return loaded, nil
}
artifact, err := m.repo.Artifact(ctx, pluginRecord.DesiredArtifactID)
if err != nil {
return nil, err
}
if artifact.Status == ArtifactStatusDeleted || artifact.Status == ArtifactStatusRejected {
return nil, fmt.Errorf("artifact status %q is not loadable", artifact.Status)
}
gateway := NewGateway(pluginRecord.ID, m.handleConn, m.wg)
instance, err := m.adapter.Load(ctx, artifact, pluginRecord, gateway)
if err != nil {
_ = m.repo.MarkRuntime(ctx, pluginRecord.ID, RuntimeFailed, "", "", pluginRecord.AppliedGeneration, err.Error(), map[string]any{"error": err.Error()}, nil)
return nil, err
}
handlers := buildHandlers(pluginRecord, artifact, gateway)
loaded := &loadedPlugin{
record: pluginRecord,
artifact: artifact,
instance: instance,
gateway: gateway,
handlers: handlers,
}
m.loaded[pluginRecord.ID] = loaded
if err := m.repo.MarkRuntime(ctx, pluginRecord.ID, RuntimeLoaded, "", artifact.ID, pluginRecord.AppliedGeneration, "", map[string]any{
"handler_count": len(handlers),
}, handlerSummaries(handlers)); err != nil {
return nil, err
}
return loaded, nil
}
func (m *Manager) markEnabled(ctx context.Context, loaded *loadedPlugin) error {
return m.repo.MarkRuntime(ctx, loaded.record.ID, RuntimeEnabled, loaded.artifact.ID, loaded.artifact.ID, loaded.record.DesiredGeneration, "", map[string]any{
"handler_count": len(loaded.handlers),
}, handlerSummaries(loaded.handlers))
}
func (m *Manager) currentHandlersLocked() map[string][]*upstreamHandler {
current := make(map[string][]*upstreamHandler)
value := m.snapshot.Load()
if handlers, ok := value.([]*upstreamHandler); ok {
for _, handler := range handlers {
current[handler.pluginID] = append(current[handler.pluginID], handler)
}
}
return current
}
func (m *Manager) removeFromDispatchLocked(pluginID string) {
current := m.currentHandlersLocked()
delete(current, pluginID)
m.publish(flattenHandlers(current))
}
func (m *Manager) publish(handlers []*upstreamHandler) {
sort.SliceStable(handlers, func(i, j int) bool {
if handlers[i].priority != handlers[j].priority {
return handlers[i].priority < handlers[j].priority
}
if handlers[i].pluginID != handlers[j].pluginID {
return handlers[i].pluginID < handlers[j].pluginID
}
return handlers[i].handlerID < handlers[j].handlerID
})
m.snapshot.Store(handlers)
}
func buildHandlers(pluginRecord PluginRecord, artifact ArtifactRecord, gateway *Gateway) []*upstreamHandler {
timeout := DefaultHandlerTimeout
var manifest Manifest
if err := json.Unmarshal([]byte(artifact.MetadataJSON), &manifest); err == nil && manifest.RuntimeLimits.HandlerTimeoutMS > 0 {
timeout = time.Duration(manifest.RuntimeLimits.HandlerTimeoutMS) * time.Millisecond
}
var handlers []*upstreamHandler
if hook, ok := gateway.UpstreamConnectHandler(); ok {
handlers = append(handlers, &upstreamHandler{
pluginID: pluginRecord.ID,
artifactID: artifact.ID,
priority: pluginRecord.Priority,
handlerID: "upstream.connect/v1",
timeout: timeout,
accept: hook.Acceptor(),
handle: hook.Handler(),
})
}
if hook, ok := gateway.LegacyUpstreamHandler(); ok {
acceptor := hook.Acceptor()
handler := hook.Handler()
handlers = append(handlers, &upstreamHandler{
pluginID: pluginRecord.ID,
artifactID: artifact.ID,
priority: pluginRecord.Priority,
handlerID: "legacy-upstream",
timeout: timeout,
accept: func(req api.UpstreamConnectRequest) bool {
return acceptor(req.Source, req.Upstream)
},
handle: func(req api.UpstreamConnectRequest) (net.Conn, error) {
return handler(req.Source, req.Upstream)
},
})
}
return handlers
}
func (h *upstreamHandler) invoke(req api.UpstreamConnectRequest) (conn net.Conn, err error) {
h.calls.Add(1)
ctx := req.Context
if ctx == nil {
ctx = context.Background()
}
if h.timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, h.timeout)
defer cancel()
}
req.Context = ctx
done := make(chan result, 1)
go func() {
defer func() {
if rec := recover(); rec != nil {
h.panics.Add(1)
done <- result{err: fmt.Errorf("plugin %s panic: %v", h.pluginID, rec)}
}
}()
conn, err := h.handle(req)
done <- result{conn: conn, err: err}
}()
select {
case <-ctx.Done():
h.timeouts.Add(1)
go closeLateConn(done)
return nil, ctx.Err()
case result := <-done:
if result.err != nil && !errors.Is(result.err, api.ErrPass) {
h.errors.Add(1)
}
return result.conn, result.err
}
}
type result struct {
conn net.Conn
err error
}
func closeLateConn(done <-chan result) {
result := <-done
if result.conn != nil {
_ = result.conn.Close()
}
}
func flattenHandlers(byPlugin map[string][]*upstreamHandler) []*upstreamHandler {
var handlers []*upstreamHandler
for _, pluginHandlers := range byPlugin {
handlers = append(handlers, pluginHandlers...)
}
return handlers
}
func handlerSummaries(handlers []*upstreamHandler) []DispatchHandlerSummary {
summaries := make([]DispatchHandlerSummary, 0, len(handlers))
for _, handler := range handlers {
summaries = append(summaries, DispatchHandlerSummary{
PluginID: handler.pluginID,
ArtifactID: handler.artifactID,
Priority: handler.priority,
HandlerID: handler.handlerID,
ExtensionPoint: ExtensionUpstreamConnect,
TimeoutMS: handler.timeout.Milliseconds(),
Calls: handler.calls.Load(),
Errors: handler.errors.Load(),
Panics: handler.panics.Load(),
Timeouts: handler.timeouts.Load(),
})
}
return summaries
}

View File

@@ -0,0 +1,323 @@
package pluginmanager
import (
"context"
"database/sql"
"errors"
"net"
"path/filepath"
"testing"
"github.com/tursom/mc-gateway/internal/admindb"
"github.com/tursom/mc-gateway/plugin/api"
)
func TestManagerUploadDoesNotLoadPlugin(t *testing.T) {
adapter := &fakeAdapter{}
manager := newManagerForTest(t, adapter)
artifact := uploadTestArtifact(t, manager, "plugin-a")
if artifact.PluginID != "plugin-a" {
t.Fatalf("artifact plugin = %q, want plugin-a", artifact.PluginID)
}
if adapter.loads != 0 {
t.Fatalf("adapter loads = %d, want 0 for upload-only validation", adapter.loads)
}
}
func TestManagerEnableDisableAndDispatch(t *testing.T) {
adapter := &fakeAdapter{}
manager := newManagerForTest(t, adapter)
artifact := uploadTestArtifact(t, manager, "plugin-a")
if _, err := manager.SetDesired(context.Background(), "admin", "plugin-a", artifact.ID, DesiredDisabled, `{"upstream":"override"}`, 10); err != nil {
t.Fatalf("SetDesired() error = %v", err)
}
if _, err := manager.Enable(context.Background(), "admin", "plugin-a"); err != nil {
t.Fatalf("Enable() error = %v", err)
}
if adapter.loads != 1 {
t.Fatalf("adapter loads = %d, want 1", adapter.loads)
}
result, err := manager.ConnectUpstream(context.Background(), api.UpstreamConnectRequest{
Host: "play.example",
Upstream: "backend.example:25565",
})
if err != nil {
t.Fatalf("ConnectUpstream() error = %v", err)
}
if !result.Handled || result.Conn == nil {
t.Fatalf("ConnectUpstream() = %+v, want handled conn", result)
}
plugin, err := manager.Disable(context.Background(), "admin", "plugin-a")
if err != nil {
t.Fatalf("Disable() error = %v", err)
}
if plugin.RuntimeState != RuntimeDisabled {
t.Fatalf("disabled runtime state = %q, want %q", plugin.RuntimeState, RuntimeDisabled)
}
result, err = manager.ConnectUpstream(context.Background(), api.UpstreamConnectRequest{
Host: "play.example",
Upstream: "backend.example:25565",
})
if err != nil {
t.Fatalf("ConnectUpstream(disabled) error = %v", err)
}
if result.Handled {
t.Fatalf("ConnectUpstream(disabled) = %+v, want pass-through", result)
}
}
func TestManagerErrPassContinuesToNextHandler(t *testing.T) {
adapter := &fakeAdapter{
handlers: map[string]api.UpstreamConnectHandler{
"plugin-a": func(api.UpstreamConnectRequest) (net.Conn, error) {
return nil, api.ErrPass
},
"plugin-b": func(api.UpstreamConnectRequest) (net.Conn, error) {
return newMemoryConn(), nil
},
},
}
manager := newManagerForTest(t, adapter)
artifactA := uploadTestArtifact(t, manager, "plugin-a")
artifactB := uploadTestArtifact(t, manager, "plugin-b")
if _, err := manager.SetDesired(context.Background(), "admin", "plugin-a", artifactA.ID, DesiredEnabled, `{}`, 10); err != nil {
t.Fatalf("SetDesired(a) error = %v", err)
}
if _, err := manager.SetDesired(context.Background(), "admin", "plugin-b", artifactB.ID, DesiredEnabled, `{}`, 20); err != nil {
t.Fatalf("SetDesired(b) error = %v", err)
}
if err := manager.Reconcile(context.Background()); err != nil {
t.Fatalf("Reconcile() error = %v", err)
}
result, err := manager.ConnectUpstream(context.Background(), api.UpstreamConnectRequest{Host: "play.example", Upstream: "backend"})
if err != nil {
t.Fatalf("ConnectUpstream() error = %v", err)
}
if !result.Handled || result.Conn == nil {
t.Fatalf("ConnectUpstream() = %+v, want second handler conn", result)
}
plan := manager.DispatchPlan(context.Background())
if len(plan.Handlers) != 2 {
t.Fatalf("dispatch handlers = %d, want 2", len(plan.Handlers))
}
if plan.Handlers[0].PluginID != "plugin-a" || plan.Handlers[1].PluginID != "plugin-b" {
t.Fatalf("dispatch order = %+v, want plugin-a then plugin-b", plan.Handlers)
}
}
func TestManagerPanicDoesNotReplaceExistingDispatch(t *testing.T) {
adapter := &fakeAdapter{
handlers: map[string]api.UpstreamConnectHandler{
"plugin-a": func(api.UpstreamConnectRequest) (net.Conn, error) {
return newMemoryConn(), nil
},
"plugin-b": func(api.UpstreamConnectRequest) (net.Conn, error) {
panic("boom")
},
},
}
manager := newManagerForTest(t, adapter)
artifactA := uploadTestArtifact(t, manager, "plugin-a")
artifactB := uploadTestArtifact(t, manager, "plugin-b")
if _, err := manager.SetDesired(context.Background(), "admin", "plugin-a", artifactA.ID, DesiredEnabled, `{}`, 10); err != nil {
t.Fatalf("SetDesired(a) error = %v", err)
}
if _, err := manager.Enable(context.Background(), "admin", "plugin-a"); err != nil {
t.Fatalf("Enable(a) error = %v", err)
}
if _, err := manager.SetDesired(context.Background(), "admin", "plugin-b", artifactB.ID, DesiredEnabled, `{}`, 5); err != nil {
t.Fatalf("SetDesired(b) error = %v", err)
}
if _, err := manager.Enable(context.Background(), "admin", "plugin-b"); err != nil {
t.Fatalf("Enable(b) error = %v", err)
}
_, err := manager.ConnectUpstream(context.Background(), api.UpstreamConnectRequest{Host: "play.example", Upstream: "backend"})
if err == nil {
t.Fatal("ConnectUpstream() error = nil, want panic converted to error")
}
plan := manager.DispatchPlan(context.Background())
if len(plan.Handlers) != 2 {
t.Fatalf("dispatch handlers = %d, want 2", len(plan.Handlers))
}
if plan.Handlers[0].PluginID != "plugin-b" || plan.Handlers[0].Panics != 1 {
t.Fatalf("first handler summary = %+v, want plugin-b panic count", plan.Handlers[0])
}
}
func TestManagerLoadFailureKeepsExistingDispatch(t *testing.T) {
adapter := &fakeAdapter{
loadErrs: map[string]error{
"plugin-b": errors.New("open failed"),
},
}
manager := newManagerForTest(t, adapter)
artifactA := uploadTestArtifact(t, manager, "plugin-a")
artifactB := uploadTestArtifact(t, manager, "plugin-b")
if _, err := manager.SetDesired(context.Background(), "admin", "plugin-a", artifactA.ID, DesiredEnabled, `{}`, 10); err != nil {
t.Fatalf("SetDesired(a) error = %v", err)
}
if _, err := manager.Enable(context.Background(), "admin", "plugin-a"); err != nil {
t.Fatalf("Enable(a) error = %v", err)
}
if _, err := manager.SetDesired(context.Background(), "admin", "plugin-b", artifactB.ID, DesiredEnabled, `{}`, 5); err != nil {
t.Fatalf("SetDesired(b) error = %v", err)
}
if _, err := manager.Enable(context.Background(), "admin", "plugin-b"); err == nil {
t.Fatal("Enable(b) error = nil, want load failure")
}
plan := manager.DispatchPlan(context.Background())
if len(plan.Handlers) != 1 || plan.Handlers[0].PluginID != "plugin-a" {
t.Fatalf("dispatch plan after failed enable = %+v, want only plugin-a", plan)
}
result, err := manager.ConnectUpstream(context.Background(), api.UpstreamConnectRequest{Host: "play.example", Upstream: "backend"})
if err != nil {
t.Fatalf("ConnectUpstream() error = %v", err)
}
if !result.Handled || result.Conn == nil {
t.Fatalf("ConnectUpstream() = %+v, want existing plugin-a conn", result)
}
}
func TestManagerReconcileRestoresEnabledPlugin(t *testing.T) {
db := openPluginManagerTestDB(t)
root := t.TempDir()
firstAdapter := &fakeAdapter{}
first := New(Options{
DB: db,
ArtifactRoot: root,
Adapter: firstAdapter,
})
artifact := uploadTestArtifact(t, first, "plugin-a")
if _, err := first.SetDesired(context.Background(), "admin", "plugin-a", artifact.ID, DesiredEnabled, `{}`, 10); err != nil {
t.Fatalf("SetDesired() error = %v", err)
}
if _, err := first.Enable(context.Background(), "admin", "plugin-a"); err != nil {
t.Fatalf("Enable() error = %v", err)
}
secondAdapter := &fakeAdapter{}
second := New(Options{
DB: db,
ArtifactRoot: root,
Adapter: secondAdapter,
})
if err := second.Reconcile(context.Background()); err != nil {
t.Fatalf("Reconcile() error = %v", err)
}
if secondAdapter.loads != 1 {
t.Fatalf("reconcile loads = %d, want 1", secondAdapter.loads)
}
result, err := second.ConnectUpstream(context.Background(), api.UpstreamConnectRequest{Host: "play.example", Upstream: "backend"})
if err != nil {
t.Fatalf("ConnectUpstream() error = %v", err)
}
if !result.Handled || result.Conn == nil {
t.Fatalf("ConnectUpstream() = %+v, want restored handler conn", result)
}
}
func TestAcceptorPanicIsRecovered(t *testing.T) {
handler := &upstreamHandler{
pluginID: "acceptor",
accept: func(api.UpstreamConnectRequest) bool {
panic("boom")
},
}
accepted, err := handler.accepts(api.UpstreamConnectRequest{})
if err == nil {
t.Fatal("accepts() error = nil, want panic error")
}
if accepted {
t.Fatal("accepts() accepted = true, want false")
}
if handler.panics.Load() != 1 {
t.Fatalf("panics = %d, want 1", handler.panics.Load())
}
}
func newManagerForTest(t *testing.T, adapter RuntimeAdapter) *Manager {
t.Helper()
db := openPluginManagerTestDB(t)
return New(Options{
DB: db,
ArtifactRoot: t.TempDir(),
Adapter: adapter,
})
}
func openPluginManagerTestDB(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 uploadTestArtifact(t *testing.T, manager *Manager, pluginID string) ArtifactRecord {
t.Helper()
packagePath := writeTestMCGP(t, map[string][]byte{
"manifest.json": testManifestBytes(t, pluginID),
"plugin.so": []byte("fake plugin bytes " + pluginID),
})
artifact, err := manager.UploadArtifact(context.Background(), ArtifactUpload{
SourcePath: packagePath,
FileName: pluginID + ".mcgp",
Actor: "admin",
})
if err != nil {
t.Fatalf("UploadArtifact(%s) error = %v", pluginID, err)
}
return artifact
}
type fakeAdapter struct {
loads int
handlers map[string]api.UpstreamConnectHandler
loadErr error
loadErrs map[string]error
}
func (a *fakeAdapter) Load(_ context.Context, artifact ArtifactRecord, _ PluginRecord, gateway *Gateway) (api.Plugin, error) {
a.loads++
if a.loadErr != nil {
return nil, a.loadErr
}
if a.loadErrs != nil && a.loadErrs[artifact.PluginID] != nil {
return nil, a.loadErrs[artifact.PluginID]
}
handler := api.UpstreamConnectHandler(func(api.UpstreamConnectRequest) (net.Conn, error) {
return newMemoryConn(), nil
})
if a.handlers != nil && a.handlers[artifact.PluginID] != nil {
handler = a.handlers[artifact.PluginID]
}
if err := api.RegisterHookHandler(
gateway,
api.HookUpstreamConnect,
func(api.UpstreamConnectRequest) bool { return true },
handler,
); err != nil {
return nil, err
}
return &fakePlugin{}, nil
}
type fakePlugin struct {
api.AbstractPlugin
}
func newMemoryConn() net.Conn {
left, right := net.Pipe()
_ = right.Close()
return left
}

View File

@@ -0,0 +1,337 @@
package pluginmanager
import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
)
type Repository struct {
db *sql.DB
now func() time.Time
}
func NewRepository(db *sql.DB) Repository {
return Repository{
db: db,
now: time.Now,
}
}
func NewRepositoryWithClock(db *sql.DB, now func() time.Time) Repository {
repo := NewRepository(db)
if now != nil {
repo.now = now
}
return repo
}
func (r Repository) SaveArtifact(ctx context.Context, artifact ArtifactRecord) error {
_, err := r.db.ExecContext(ctx, `
INSERT INTO plugin_artifacts(
id, plugin_id, version, file_name, file_path, sha256, package_sha256, size_bytes,
artifact_type, runtime_type, runtime_entry, status, metadata_json,
capabilities_summary_json, extension_points_json, api_version, go_version, go_os, go_arch,
uploaded_by, error, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
file_name = excluded.file_name,
file_path = excluded.file_path,
package_sha256 = excluded.package_sha256,
status = excluded.status,
metadata_json = excluded.metadata_json,
capabilities_summary_json = excluded.capabilities_summary_json,
extension_points_json = excluded.extension_points_json,
uploaded_by = excluded.uploaded_by,
error = excluded.error,
updated_at = excluded.updated_at`,
artifact.ID, artifact.PluginID, artifact.Version, artifact.FileName, artifact.FilePath, artifact.SHA256, artifact.PackageSHA256, artifact.SizeBytes,
artifact.ArtifactType, artifact.RuntimeType, artifact.RuntimeEntry, artifact.Status, artifact.MetadataJSON,
artifact.CapabilitiesSummaryJSON, artifact.ExtensionPointsJSON, artifact.APIVersion, artifact.GoVersion, artifact.GOOS, artifact.GOARCH,
artifact.UploadedBy, artifact.Error, artifact.CreatedAt, artifact.UpdatedAt)
return err
}
func (r Repository) Artifact(ctx context.Context, id string) (ArtifactRecord, error) {
row := r.db.QueryRowContext(ctx, `
SELECT id, plugin_id, version, file_name, file_path, sha256, package_sha256, size_bytes,
artifact_type, runtime_type, runtime_entry, status, metadata_json,
capabilities_summary_json, extension_points_json, api_version, go_version, go_os, go_arch,
uploaded_by, error, created_at, updated_at
FROM plugin_artifacts
WHERE id = ?`, id)
return scanArtifact(row)
}
func (r Repository) ListArtifacts(ctx context.Context, pluginID string) ([]ArtifactRecord, error) {
query := `
SELECT id, plugin_id, version, file_name, file_path, sha256, package_sha256, size_bytes,
artifact_type, runtime_type, runtime_entry, status, metadata_json,
capabilities_summary_json, extension_points_json, api_version, go_version, go_os, go_arch,
uploaded_by, error, created_at, updated_at
FROM plugin_artifacts`
var args []any
if pluginID != "" {
query += ` WHERE plugin_id = ?`
args = append(args, pluginID)
}
query += ` ORDER BY created_at DESC, id DESC`
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var artifacts []ArtifactRecord
for rows.Next() {
artifact, err := scanArtifact(rows)
if err != nil {
return nil, err
}
artifacts = append(artifacts, artifact)
}
return artifacts, rows.Err()
}
func (r Repository) UpsertDesired(ctx context.Context, actor, pluginID, artifactID, desiredState, configJSON string, priority int) (PluginRecord, error) {
if desiredState == "" {
desiredState = DesiredDisabled
}
if configJSON == "" {
configJSON = "{}"
}
if !json.Valid([]byte(configJSON)) {
return PluginRecord{}, errors.New("config_json must be valid JSON")
}
if priority == 0 {
priority = DefaultPriority
}
switch desiredState {
case DesiredEnabled, DesiredDisabled, DesiredDeleted:
default:
return PluginRecord{}, errors.New("invalid desired_state")
}
artifact, err := r.Artifact(ctx, artifactID)
if err != nil {
return PluginRecord{}, err
}
if artifact.PluginID != pluginID {
return PluginRecord{}, errors.New("artifact plugin_id does not match")
}
now := r.now().Unix()
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return PluginRecord{}, err
}
defer tx.Rollback()
var existing PluginRecord
row := tx.QueryRowContext(ctx, `
SELECT id, desired_artifact_id, active_artifact_id, loaded_artifact_id, desired_state, runtime_state,
priority, config_json, desired_generation, applied_generation, last_error,
runtime_summary_json, dispatch_summary_json, created_at, updated_at, updated_by
FROM plugins WHERE id = ?`, pluginID)
err = scanPluginRow(row, &existing)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return PluginRecord{}, err
}
nextGeneration := int64(1)
createdAt := now
if err == nil {
nextGeneration = existing.DesiredGeneration + 1
createdAt = existing.CreatedAt
if _, err := tx.ExecContext(ctx, `
INSERT INTO plugin_config_snapshots(plugin_id, artifact_id, config_json, desired_state, priority, desired_generation, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
existing.ID, existing.DesiredArtifactID, existing.ConfigJSON, existing.DesiredState, existing.Priority, existing.DesiredGeneration, actor, now); err != nil {
return PluginRecord{}, err
}
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO plugins(
id, desired_artifact_id, active_artifact_id, loaded_artifact_id, desired_state, runtime_state,
priority, config_json, desired_generation, applied_generation, last_error,
runtime_summary_json, dispatch_summary_json, deleted_at, created_at, updated_at, updated_by
) VALUES (?, ?, '', '', ?, ?, ?, ?, ?, 0, '', '{}', '{}', 0, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
desired_artifact_id = excluded.desired_artifact_id,
desired_state = excluded.desired_state,
priority = excluded.priority,
config_json = excluded.config_json,
desired_generation = excluded.desired_generation,
deleted_at = CASE WHEN excluded.desired_state = 'deleted' THEN excluded.updated_at ELSE 0 END,
updated_at = excluded.updated_at,
updated_by = excluded.updated_by`,
pluginID, artifactID, desiredState, RuntimeDisabled, priority, configJSON, nextGeneration, createdAt, now, actor); err != nil {
return PluginRecord{}, err
}
if err := tx.Commit(); err != nil {
return PluginRecord{}, err
}
return r.Plugin(ctx, pluginID)
}
func (r Repository) Plugin(ctx context.Context, id string) (PluginRecord, error) {
row := r.db.QueryRowContext(ctx, `
SELECT id, desired_artifact_id, active_artifact_id, loaded_artifact_id, desired_state, runtime_state,
priority, config_json, desired_generation, applied_generation, last_error,
runtime_summary_json, dispatch_summary_json, created_at, updated_at, updated_by
FROM plugins
WHERE id = ? AND desired_state <> 'deleted'`, id)
var plugin PluginRecord
if err := scanPluginRow(row, &plugin); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return PluginRecord{}, ErrPluginNotFound
}
return PluginRecord{}, err
}
return plugin, nil
}
func (r Repository) ListPlugins(ctx context.Context) ([]PluginRecord, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, desired_artifact_id, active_artifact_id, loaded_artifact_id, desired_state, runtime_state,
priority, config_json, desired_generation, applied_generation, last_error,
runtime_summary_json, dispatch_summary_json, created_at, updated_at, updated_by
FROM plugins
WHERE desired_state <> 'deleted'
ORDER BY priority ASC, id ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var plugins []PluginRecord
for rows.Next() {
var plugin PluginRecord
if err := scanPluginRow(rows, &plugin); err != nil {
return nil, err
}
plugins = append(plugins, plugin)
}
return plugins, rows.Err()
}
func (r Repository) DesiredEnabled(ctx context.Context) ([]PluginRecord, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, desired_artifact_id, active_artifact_id, loaded_artifact_id, desired_state, runtime_state,
priority, config_json, desired_generation, applied_generation, last_error,
runtime_summary_json, dispatch_summary_json, created_at, updated_at, updated_by
FROM plugins
WHERE desired_state = 'enabled'
ORDER BY priority ASC, id ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var plugins []PluginRecord
for rows.Next() {
var plugin PluginRecord
if err := scanPluginRow(rows, &plugin); err != nil {
return nil, err
}
plugins = append(plugins, plugin)
}
return plugins, rows.Err()
}
func (r Repository) MarkRuntime(ctx context.Context, pluginID, runtimeState, activeArtifactID, loadedArtifactID string, appliedGeneration int64, lastError string, runtimeSummary, dispatchSummary any) error {
now := r.now().Unix()
runtimeJSON, err := marshalDefaultObject(runtimeSummary)
if err != nil {
return err
}
dispatchJSON, err := marshalDefaultObject(dispatchSummary)
if err != nil {
return err
}
_, err = r.db.ExecContext(ctx, `
UPDATE plugins
SET runtime_state = ?, active_artifact_id = ?, loaded_artifact_id = ?, applied_generation = ?,
last_error = ?, runtime_summary_json = ?, dispatch_summary_json = ?, updated_at = ?
WHERE id = ?`,
runtimeState, activeArtifactID, loadedArtifactID, appliedGeneration, lastError, runtimeJSON, dispatchJSON, now, pluginID)
return err
}
func (r Repository) UpdateArtifactStatus(ctx context.Context, artifactID, status, message string) error {
_, err := r.db.ExecContext(ctx, `UPDATE plugin_artifacts SET status = ?, error = ?, updated_at = ? WHERE id = ?`,
status, message, r.now().Unix(), artifactID)
return err
}
func (r Repository) RecordOperation(ctx context.Context, pluginID, artifactID, operation, status, actor, message string, metadata any) error {
metadataJSON, err := marshalDefaultObject(metadata)
if err != nil {
return err
}
_, err = r.db.ExecContext(ctx, `
INSERT INTO plugin_operations(plugin_id, artifact_id, operation, status, actor, message, metadata_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
pluginID, artifactID, operation, status, actor, message, metadataJSON, r.now().Unix())
return err
}
func (r Repository) DispatchPlan(ctx context.Context) (DispatchPlan, error) {
plugins, err := r.ListPlugins(ctx)
if err != nil {
return DispatchPlan{}, err
}
plan := DispatchPlan{UpdatedAt: r.now().Unix()}
for _, plugin := range plugins {
if plugin.DispatchSummaryJSON == "" || plugin.RuntimeState != RuntimeEnabled {
continue
}
var summaries []DispatchHandlerSummary
if err := json.Unmarshal([]byte(plugin.DispatchSummaryJSON), &summaries); err == nil {
plan.Handlers = append(plan.Handlers, summaries...)
}
}
return plan, nil
}
type rowScanner interface {
Scan(dest ...any) error
}
func scanArtifact(row rowScanner) (ArtifactRecord, error) {
var artifact ArtifactRecord
err := row.Scan(
&artifact.ID, &artifact.PluginID, &artifact.Version, &artifact.FileName, &artifact.FilePath, &artifact.SHA256, &artifact.PackageSHA256, &artifact.SizeBytes,
&artifact.ArtifactType, &artifact.RuntimeType, &artifact.RuntimeEntry, &artifact.Status, &artifact.MetadataJSON,
&artifact.CapabilitiesSummaryJSON, &artifact.ExtensionPointsJSON, &artifact.APIVersion, &artifact.GoVersion, &artifact.GOOS, &artifact.GOARCH,
&artifact.UploadedBy, &artifact.Error, &artifact.CreatedAt, &artifact.UpdatedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return ArtifactRecord{}, ErrArtifactNotFound
}
return artifact, err
}
func scanPluginRow(row rowScanner, plugin *PluginRecord) error {
return row.Scan(
&plugin.ID, &plugin.DesiredArtifactID, &plugin.ActiveArtifactID, &plugin.LoadedArtifactID, &plugin.DesiredState, &plugin.RuntimeState,
&plugin.Priority, &plugin.ConfigJSON, &plugin.DesiredGeneration, &plugin.AppliedGeneration, &plugin.LastError,
&plugin.RuntimeSummaryJSON, &plugin.DispatchSummaryJSON, &plugin.CreatedAt, &plugin.UpdatedAt, &plugin.UpdatedBy,
)
}
func marshalDefaultObject(value any) (string, error) {
if value == nil {
return "{}", nil
}
data, err := json.Marshal(value)
if err != nil {
return "", err
}
if len(data) == 0 || string(data) == "null" {
return "{}", nil
}
return string(data), nil
}

View File

@@ -0,0 +1,233 @@
package pluginmanager
import (
"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"
RuntimeGoPlugin = "go-plugin"
RuntimeEntry = "plugin.so"
ExtensionUpstreamConnect = "upstream.connect/v1"
ArtifactStatusUploaded = "uploaded"
ArtifactStatusValidated = "validated"
ArtifactStatusLoadable = "loadable"
ArtifactStatusLoaded = "loaded"
ArtifactStatusRejected = "rejected"
ArtifactStatusDeleted = "deleted"
DesiredEnabled = "enabled"
DesiredDisabled = "disabled"
DesiredDeleted = "deleted"
RuntimeNotLoaded = "not_loaded"
RuntimeLoaded = "loaded"
RuntimeEnabled = "enabled"
RuntimeFailed = "failed"
RuntimeDisabled = "disabled"
DefaultPriority = 100
DefaultHandlerTimeout = 3 * time.Second
DefaultManifestMaxBytes = 256 * 1024
DefaultPackageMaxBytes = 64 * 1024 * 1024
DefaultPackageMaxEntries = 2048
DefaultExtractedMaxBytes = 256 * 1024 * 1024
DefaultNonRuntimeMaxBytes = 16 * 1024 * 1024
)
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"`
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"`
SupplyChain json.RawMessage `json:"supply_chain"`
}
type RuntimeManifest struct {
Type string `json:"type"`
Entry string `json:"entry"`
EntrySymbol string `json:"entry_symbol"`
MetadataSymbol string `json:"metadata_symbol"`
}
type ExtensionPoint struct {
Type string `json:"type"`
Key string `json:"key"`
}
type RuntimeLimits struct {
HandlerTimeoutMS int `json:"handler_timeout_ms"`
}
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 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 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"`
TimeoutMS int64 `json:"timeout_ms"`
Calls uint64 `json:"calls"`
Errors uint64 `json:"errors"`
Panics uint64 `json:"panics"`
Timeouts uint64 `json:"timeouts"`
}
type UpstreamResult struct {
Conn net.Conn
Handled bool
}
type Gateway struct {
PluginID string
handleConn func(net.Conn)
wg *sync.WaitGroup
hooks map[string]any
}
func NewGateway(pluginID string, handleConn func(net.Conn), wg *sync.WaitGroup) *Gateway {
return &Gateway{
PluginID: pluginID,
handleConn: handleConn,
wg: wg,
hooks: make(map[string]any),
}
}
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) 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

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

View File

@@ -1,6 +1,7 @@
package api
import (
"context"
"errors"
"net"
"unsafe"
@@ -8,6 +9,8 @@ import (
var (
UnsupportedHookType = errors.New("unsupported hook type")
ErrPass = errors.New("plugin handler pass")
ErrBlocked = errors.New("plugin handler blocked")
)
type (
@@ -19,9 +22,28 @@ type (
acceptor Accept
handler Handler
}
UpstreamConnectRequest struct {
Context context.Context
Source net.Conn
Host string
Upstream string
InitialData []byte
Metadata map[string]string
}
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),