Compare commits

...

2 Commits

Author SHA1 Message Date
tursom
54f5c322e9 feat(plugin): add future runtime distribution controls
Some checks failed
Go / build (.exe, 386, windows, windows-386) (push) Has been cancelled
Go / build (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Go / build (.exe, arm64, windows, windows-arm64) (push) Has been cancelled
Go / build (386, freebsd, freebsd-386) (push) Has been cancelled
Go / build (386, linux, linux-386) (push) Has been cancelled
Go / build (386, netbsd, netbsd-386) (push) Has been cancelled
Go / build (386, openbsd, openbsd-386) (push) Has been cancelled
Go / build (386, plan9, plan9-386) (push) Has been cancelled
Go / build (amd64, darwin, darwin-amd64) (push) Has been cancelled
Go / build (amd64, dragonfly, dragonfly-amd64) (push) Has been cancelled
Go / build (amd64, freebsd, freebsd-amd64) (push) Has been cancelled
Go / build (amd64, illumos, illumos-amd64) (push) Has been cancelled
Go / build (amd64, linux, linux-amd64) (push) Has been cancelled
Go / build (amd64, netbsd, netbsd-amd64) (push) Has been cancelled
Go / build (amd64, openbsd, openbsd-amd64) (push) Has been cancelled
Go / build (amd64, plan9, plan9-amd64) (push) Has been cancelled
Go / build (amd64, solaris, solaris-amd64) (push) Has been cancelled
Go / build (arm, 6, linux, linux-armv6) (push) Has been cancelled
Go / build (arm, 7, linux, linux-armv7) (push) Has been cancelled
Go / build (arm, freebsd, freebsd-arm) (push) Has been cancelled
Go / build (arm, netbsd, netbsd-arm) (push) Has been cancelled
Go / build (arm, openbsd, openbsd-arm) (push) Has been cancelled
Go / build (arm, plan9, plan9-arm) (push) Has been cancelled
Go / build (arm64, darwin, darwin-arm64) (push) Has been cancelled
Go / build (arm64, freebsd, freebsd-arm64) (push) Has been cancelled
Go / build (arm64, linux, linux-arm64) (push) Has been cancelled
Go / build (arm64, netbsd, netbsd-arm64) (push) Has been cancelled
Go / build (arm64, openbsd, openbsd-arm64) (push) Has been cancelled
Go / build (loong64, linux, linux-loong64) (push) Has been cancelled
Go / build (mips, linux, linux-mips) (push) Has been cancelled
Go / build (mips64, linux, linux-mips64) (push) Has been cancelled
Go / build (mips64le, linux, linux-mips64le) (push) Has been cancelled
Go / build (mipsle, linux, linux-mipsle) (push) Has been cancelled
Go / build (ppc64, aix, aix-ppc64) (push) Has been cancelled
Go / build (ppc64, linux, linux-ppc64) (push) Has been cancelled
Go / build (ppc64, openbsd, openbsd-ppc64) (push) Has been cancelled
Go / build (ppc64le, linux, linux-ppc64le) (push) Has been cancelled
Go / build (riscv64, freebsd, freebsd-riscv64) (push) Has been cancelled
Go / build (riscv64, linux, linux-riscv64) (push) Has been cancelled
Go / build (riscv64, openbsd, openbsd-riscv64) (push) Has been cancelled
Go / build (s390x, linux, linux-s390x) (push) Has been cancelled
Go / merge-artifacts (push) Has been cancelled
Docker Image / docker (push) Has been cancelled
2026-06-26 13:31:24 +08:00
tursom
f3fb924a3a feat(plugin): add extension ecosystem 2026-06-26 13:09:38 +08:00
30 changed files with 4017 additions and 143 deletions

View File

@@ -29,24 +29,28 @@ func newAdminAPIHandler() http.HandlerFunc {
AuditLogs: handleAdminAuditLogs,
PluginArtifacts: handleAdminPluginArtifacts,
PluginArtifact: handleAdminPluginArtifact,
PluginSources: handleAdminPluginSources,
PluginBuilds: handleAdminPluginBuilds,
PluginBuild: handleAdminPluginBuild,
PluginGC: handleAdminPluginGC,
PluginOperationsGC: handleAdminPluginOperationsGC,
PluginsList: handleAdminPluginsList,
PluginItem: handleAdminPluginItem,
PluginAction: handleAdminPluginAction,
PluginConfig: handleAdminPluginConfig,
PluginSecrets: handleAdminPluginSecrets,
PluginRollback: handleAdminPluginRollback,
PluginOperations: handleAdminPluginOperations,
PluginDraining: handleAdminPluginDraining,
PluginDispatch: handleAdminPluginDispatchPlan,
PluginGovernance: handleAdminPluginGovernance,
PluginAdvisories: handleAdminPluginAdvisories,
PluginDiagnostics: handleAdminPluginDiagnostics,
PluginArtifacts: handleAdminPluginArtifacts,
PluginArtifact: handleAdminPluginArtifact,
PluginSources: handleAdminPluginSources,
PluginBuilds: handleAdminPluginBuilds,
PluginBuild: handleAdminPluginBuild,
PluginGC: handleAdminPluginGC,
PluginOperationsGC: handleAdminPluginOperationsGC,
PluginsList: handleAdminPluginsList,
PluginItem: handleAdminPluginItem,
PluginAction: handleAdminPluginAction,
PluginConfig: handleAdminPluginConfig,
PluginSecrets: handleAdminPluginSecrets,
PluginRollback: handleAdminPluginRollback,
PluginOperations: handleAdminPluginOperations,
PluginDraining: handleAdminPluginDraining,
PluginDispatch: handleAdminPluginDispatchPlan,
PluginGovernance: handleAdminPluginGovernance,
PluginAdvisories: handleAdminPluginAdvisories,
PluginDiagnostics: handleAdminPluginDiagnostics,
PluginService: handleAdminPluginService,
PluginRepositories: handleAdminPluginRepositories,
PluginSupplyChain: handleAdminPluginSupplyChain,
PluginInstrumentation: handleAdminPluginInstrumentation,
})
}

View File

@@ -1,4 +1,4 @@
import type { PluginArtifact, PluginBuild, PluginView, RouteRecord, ServiceRecord, User } from "./types.js";
import type { PluginArtifact, PluginBuild, PluginInstrumentation, PluginServiceStatus, PluginView, RouteRecord, ServiceRecord, User } from "./types.js";
export const tokenStorageKey = "mcGatewayAdminToken";
export const languageStorageKey = "mcGatewayAdminLanguage";
@@ -14,6 +14,8 @@ export interface AppState {
plugins: PluginView[];
pluginArtifacts: PluginArtifact[];
pluginBuilds: PluginBuild[];
pluginService: PluginServiceStatus | null;
pluginInstrumentation: PluginInstrumentation[];
selectedPluginID: string;
selectedArtifactID: string;
}
@@ -29,6 +31,8 @@ export const state: AppState = {
plugins: [],
pluginArtifacts: [],
pluginBuilds: [],
pluginService: null,
pluginInstrumentation: [],
selectedPluginID: "",
selectedArtifactID: "",
};

View File

@@ -117,6 +117,44 @@ export interface PluginProxyConnection {
draining: boolean;
}
export interface PluginServiceState {
desired_mode: string;
active_mode: string;
applied_at?: number;
restart_required: boolean;
live_migration?: string;
last_error?: string;
updated_by?: string;
updated_at?: number;
}
export interface PluginHostRuntimeSummary {
plugin_id: string;
artifact_id: string;
state: string;
drain_mode: string;
crash_loop: boolean;
crash_count: number;
last_error?: string;
}
export interface PluginServiceStatus {
service: PluginServiceState;
hosts?: PluginHostRuntimeSummary[];
}
export interface PluginInstrumentation {
id: number;
name: string;
version: string;
profile: string;
generated_diff_hash: string;
runbook_rollback: string;
status: string;
created_by?: string;
created_at?: number;
}
export interface GovernanceIssue {
code: string;
severity: string;
@@ -176,6 +214,7 @@ export interface PluginView {
last_error?: string;
runtime_summary?: Record<string, unknown>;
dispatch_summary?: unknown[];
extension_status?: Record<string, unknown>;
capabilities_summary?: Record<string, unknown>;
minecraft?: unknown;
config_json?: string;

View File

@@ -3,7 +3,7 @@ import { showAlert } from "../alerts.js";
import { badge, el, escapeAttr, escapeHTML, getFormInput } from "../dom.js";
import { isAdmin } from "../session.js";
import { state } from "../state.js";
import type { PluginArtifact, PluginBuild, PluginDryRunResult, PluginOperations, PluginProxyConnection, PluginSecret, PluginSnapshot, PluginView } from "../types.js";
import type { PluginArtifact, PluginBuild, PluginDryRunResult, PluginInstrumentation, PluginOperations, PluginProxyConnection, PluginSecret, PluginServiceStatus, PluginSnapshot, PluginView } from "../types.js";
interface PluginsResponse {
plugins?: PluginView[];
@@ -17,6 +17,14 @@ interface BuildsResponse {
builds?: PluginBuild[];
}
interface PluginServiceResponse {
plugin_service?: PluginServiceStatus;
}
interface InstrumentationResponse {
instrumentation?: PluginInstrumentation[];
}
interface PluginResponse {
plugin?: PluginView;
}
@@ -42,14 +50,18 @@ interface OperationsResponse {
export async function loadPlugins(): Promise<void> {
try {
const [data, artifacts, builds] = await Promise.all([
const [data, artifacts, builds, service, instrumentation] = await Promise.all([
api<PluginsResponse>("/plugins"),
api<ArtifactsResponse>("/plugin-artifacts"),
api<BuildsResponse>("/plugin-builds"),
api<PluginServiceResponse>("/plugin-service"),
api<InstrumentationResponse>("/plugin-instrumentation"),
]);
state.plugins = data.plugins || [];
state.pluginArtifacts = artifacts.artifacts || [];
state.pluginBuilds = builds.builds || [];
state.pluginService = service.plugin_service || null;
state.pluginInstrumentation = instrumentation.instrumentation || [];
const firstPlugin = state.plugins[0];
if (!state.selectedPluginID && firstPlugin) {
state.selectedPluginID = firstPlugin.id;
@@ -68,6 +80,7 @@ export async function loadPlugins(): Promise<void> {
export function renderPlugins(): void {
const managed = new Set(state.plugins.map((plugin) => plugin.id));
const unmanagedArtifacts = state.pluginArtifacts.filter((artifact) => !managed.has(artifact.plugin_id));
renderPluginServicePanel();
el("pluginsBody").innerHTML = state.plugins.map((plugin) => `
<tr class="${plugin.id === state.selectedPluginID ? "selected" : ""}">
<td><button class="link-button" type="button" data-plugin-detail="${escapeAttr(plugin.id)}">${escapeHTML(plugin.id)}</button></td>
@@ -216,6 +229,10 @@ export function renderPluginDetail(plugin: PluginView | null = selectedPlugin())
<h3>Dispatch plan</h3>
<pre class="log-output">${escapeHTML(formatJSON(plugin.dispatch_summary || []))}</pre>
</section>
<section class="panel">
<h3>Extension status</h3>
<pre class="log-output">${escapeHTML(formatJSON(plugin.extension_status || {}))}</pre>
</section>
<section class="panel">
<h3>Operations</h3>
<div class="row-actions">
@@ -239,6 +256,83 @@ export function bindPluginEvents(): void {
el<HTMLButtonElement>("refreshPluginsBtn").addEventListener("click", loadPlugins);
}
function renderPluginServicePanel(): void {
const container = document.getElementById("pluginServicePanel");
if (!container) {
return;
}
const service = state.pluginService?.service;
const canWrite = isAdmin();
container.innerHTML = `
<section class="panel">
<div class="detail-header compact">
<div>
<h3>Plugin Service</h3>
<p>${service ? `active ${escapeHTML(service.active_mode)} · desired ${escapeHTML(service.desired_mode)}` : "not loaded"}</p>
</div>
${service ? badge(service.restart_required ? "restart required" : "applied", service.restart_required) : ""}
</div>
${service ? `
<div class="status-grid dense">
${detailStat("Desired mode", service.desired_mode)}
${detailStat("Active mode", service.active_mode)}
${detailStat("Migration", service.live_migration || "drain-only")}
${detailStat("Restart", service.restart_required ? "required" : "not required")}
</div>
${service.last_error ? `<div class="alert inline-alert">${escapeHTML(service.last_error)}</div>` : ""}
${canWrite ? `
<form id="pluginServiceForm" class="inline-form">
<select name="desired_mode">
${["in-process", "go-plugin-process", "sandbox-process"].map((mode) => `<option value="${mode}" ${mode === service.desired_mode ? "selected" : ""}>${mode}</option>`).join("")}
</select>
<button type="submit">Set desired</button>
</form>
` : ""}
` : ""}
</section>
<section class="panel">
<h3>Build-Time Instrumentation</h3>
${instrumentationList(state.pluginInstrumentation)}
</section>
`;
const form = document.getElementById("pluginServiceForm");
if (form instanceof HTMLFormElement) {
form.addEventListener("submit", updatePluginServiceMode);
}
}
async function updatePluginServiceMode(event: Event): Promise<void> {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
try {
await api("/plugin-service", {
method: "PUT",
body: { desired_mode: getFormInput(form, "desired_mode") },
});
await loadPlugins();
} catch (err) {
showAlert((err as Error).message);
}
}
function instrumentationList(records: PluginInstrumentation[]): string {
if (!records.length) {
return `<p class="muted">No instrumentation metadata</p>`;
}
return `<table class="mini-table">
<thead><tr><th>Name</th><th>Profile</th><th>Status</th><th>Diff</th><th>Rollback</th></tr></thead>
<tbody>${records.map((record) => `
<tr>
<td>${escapeHTML(record.name)} ${escapeHTML(record.version || "")}</td>
<td>${escapeHTML(record.profile || "")}</td>
<td>${badge(record.status || "available", record.status === "blocked")}</td>
<td>${escapeHTML(shortID(record.generated_diff_hash || ""))}</td>
<td>${escapeHTML(record.runbook_rollback || "")}</td>
</tr>
`).join("")}</tbody>
</table>`;
}
async function uploadPluginPackage(event: Event): Promise<void> {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0];

View File

@@ -1,6 +1,7 @@
package main
import (
"context"
"encoding/json"
"errors"
"net/http"
@@ -791,14 +792,47 @@ func handleAdminPluginDraining(w http.ResponseWriter, r *http.Request, rawPlugin
}
func handleAdminPluginDispatchPlan(w http.ResponseWriter, r *http.Request) {
if _, ok := requireRole(w, r, adminRoleMember); !ok {
session, ok := requireRole(w, r, adminRoleMember)
if !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())})
switch r.Method {
case http.MethodGet:
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"dispatch_plan": pluginsManager.DispatchPlan(r.Context())})
case http.MethodPost:
if session.Role != adminRoleAdmin {
adminhttp.WriteAPIError(w, http.StatusForbidden, "forbidden")
return
}
var req struct {
Action string `json:"action"`
}
if !adminhttp.DecodeJSONRequest(w, r, &req) {
return
}
switch req.Action {
case "refresh-routes":
cache := pluginsManager.RefreshRouteProviders(r.Context(), session.Username)
recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_route_provider_refresh", "plugin_dispatch", "", true, "route provider cache refreshed", map[string]any{"cache_entries": len(cache)})
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"route_cache": cache})
case "replay-subscribers":
count := pluginsManager.ReplaySubscriberDeadLetters(r.Context(), session.Username)
recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_event_subscriber_replay", "plugin_dispatch", "", true, "event subscriber dead letters replay requested", map[string]any{"replayed": count})
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"replayed": count})
case "drop-subscriber-dead-letter":
count := pluginsManager.DropSubscriberDeadLetters(r.Context(), session.Username)
recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_event_subscriber_drop", "plugin_dispatch", "", true, "event subscriber dead letters dropped", map[string]any{"dropped": count})
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"dropped": count})
default:
adminhttp.WriteAPIError(w, http.StatusBadRequest, "unknown dispatch action")
}
default:
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func handleAdminPluginGovernance(w http.ResponseWriter, r *http.Request, rawSegment string) {
@@ -1157,6 +1191,7 @@ func pluginView(r *http.Request, plugin pluginmanager.PluginRecord, detail bool)
"runtime_summary": jsonObjectString(plugin.RuntimeSummaryJSON),
"dispatch_summary": jsonArrayString(plugin.DispatchSummaryJSON),
"capabilities_summary": jsonObjectString(desiredArtifact.CapabilitiesSummaryJSON),
"extension_status": pluginExtensionStatus(r.Context(), plugin.ID),
"minecraft": pluginMinecraftSummary(desiredArtifact),
"config_json": plugin.ConfigJSON,
"config_schema": jsonObjectString(manifestConfigSchemaString(manifest)),
@@ -1185,6 +1220,33 @@ func pluginView(r *http.Request, plugin pluginmanager.PluginRecord, detail bool)
return view, nil
}
func pluginExtensionStatus(ctx context.Context, pluginID string) map[string]any {
plan := pluginsManager.DispatchPlan(ctx)
filter := func(items []pluginmanager.DispatchHandlerSummary) []pluginmanager.DispatchHandlerSummary {
var out []pluginmanager.DispatchHandlerSummary
for _, item := range items {
if item.PluginID == pluginID {
out = append(out, item)
}
}
return out
}
var providers []pluginmanager.ProviderSummary
for _, provider := range plan.Providers {
if provider.PluginID == pluginID {
providers = append(providers, provider)
}
}
return map[string]any{
"routes": filter(plan.Routes),
"statuses": filter(plan.Statuses),
"middleware": filter(plan.Middleware),
"subscribers": filter(plan.Subscribers),
"providers": providers,
"route_cache": plan.RouteCache,
}
}
func pluginManifest(artifact pluginmanager.ArtifactRecord) pluginmanager.Manifest {
var manifest pluginmanager.Manifest
_ = json.Unmarshal([]byte(artifact.MetadataJSON), &manifest)
@@ -1284,3 +1346,186 @@ func writePluginManagerError(w http.ResponseWriter, err error) {
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
}
}
func handleAdminPluginService(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:
status, err := pluginsManager.PluginServiceStatus(r.Context())
if err != nil {
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
return
}
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"plugin_service": status})
case http.MethodPut:
if session.Role != adminRoleAdmin {
adminhttp.WriteAPIError(w, http.StatusForbidden, "forbidden")
return
}
var req adminhttp.PluginServiceRequest
if !adminhttp.DecodeJSONRequest(w, r, &req) {
return
}
state, err := pluginsManager.SetPluginServiceDesired(r.Context(), session.Username, req.DesiredMode)
if err != nil {
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_service_mode_update", "plugin_service", "", false, err.Error())
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
return
}
recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_service_mode_update", "plugin_service", "", true, "plugin service mode desired state updated", map[string]any{
"desired_mode": state.DesiredMode,
"active_mode": state.ActiveMode,
"restart_required": state.RestartRequired,
})
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"plugin_service": state})
case http.MethodPost:
if session.Role != adminRoleAdmin {
adminhttp.WriteAPIError(w, http.StatusForbidden, "forbidden")
return
}
if err := pluginsManager.ApplyPluginServiceMode(r.Context()); err != nil {
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
return
}
status, err := pluginsManager.PluginServiceStatus(r.Context())
if err != nil {
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
return
}
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"plugin_service": status})
default:
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func handleAdminPluginRepositories(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:
imports, err := pluginsManager.ListRepositoryImports(r.Context())
if err != nil {
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
return
}
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"imports": imports})
case http.MethodPost:
if session.Role != adminRoleAdmin {
adminhttp.WriteAPIError(w, http.StatusForbidden, "forbidden")
return
}
var req adminhttp.PluginRepositoryImportRequest
if !adminhttp.DecodeJSONRequest(w, r, &req) {
return
}
record, artifact, err := pluginsManager.ImportRepositoryArtifact(r.Context(), session.Username, pluginmanager.RepositoryImportRequest{
RepositoryType: req.RepositoryType,
IndexPath: req.IndexPath,
ArtifactID: req.ArtifactID,
PluginID: req.PluginID,
Version: req.Version,
TrustPolicy: req.TrustPolicy,
})
if err != nil {
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_repository_import", "plugin_repository", req.IndexPath, false, err.Error())
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
return
}
recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_repository_import", "plugin_artifact", artifact.ID, true, "repository artifact imported locally", map[string]any{
"plugin_id": artifact.PluginID,
"version": artifact.Version,
"auto_enable": false,
"import_id": record.ID,
})
adminhttp.WriteJSON(w, http.StatusCreated, map[string]any{"import": record, "artifact": artifact})
default:
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func handleAdminPluginSupplyChain(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:
assessments, err := pluginsManager.ListSupplyChainAssessments(r.Context(), r.URL.Query().Get("plugin_id"), r.URL.Query().Get("artifact_id"))
if err != nil {
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
return
}
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"assessments": assessments})
case http.MethodPost:
if session.Role != adminRoleAdmin {
adminhttp.WriteAPIError(w, http.StatusForbidden, "forbidden")
return
}
var req adminhttp.PluginSupplyChainRequest
if !adminhttp.DecodeJSONRequest(w, r, &req) {
return
}
assessment, err := pluginsManager.AssessSupplyChain(r.Context(), session.Username, req.PluginID, req.ArtifactID, req.Metadata)
if err != nil {
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
return
}
adminhttp.WriteJSON(w, http.StatusCreated, map[string]any{"assessment": assessment})
default:
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func handleAdminPluginInstrumentation(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:
records, err := pluginsManager.ListInstrumentation(r.Context())
if err != nil {
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
return
}
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"instrumentation": records})
case http.MethodPost:
if session.Role != adminRoleAdmin {
adminhttp.WriteAPIError(w, http.StatusForbidden, "forbidden")
return
}
var req pluginmanager.InstrumentationRequest
if !adminhttp.DecodeJSONRequest(w, r, &req) {
return
}
record, err := pluginsManager.SaveInstrumentation(r.Context(), session.Username, req)
if err != nil {
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
return
}
adminhttp.WriteJSON(w, http.StatusCreated, map[string]any{"instrumentation": record})
default:
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}

View File

@@ -99,6 +99,7 @@
</label>
<button id="refreshPluginsBtn" class="secondary" type="button" data-i18n="refresh">Refresh</button>
</div>
<div id="pluginServicePanel" class="plugin-service-panel"></div>
<div class="table-wrap">
<table>
<thead>

View File

@@ -3,9 +3,11 @@ package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net"
"strings"
"testing"
"time"
@@ -251,6 +253,81 @@ func TestHandleRequestProtocolProxyDisableSkipsNewConnections(t *testing.T) {
}
}
func TestHandleRequestRouteResolverUsesOverrideAndSQLiteFallback(t *testing.T) {
defer saveGatewayState(t)()
setGatewayTestRoutes(map[string]string{"fallback.example": "fallback-upstream:25565"})
var dialed []string
pluginsManager = pluginmanager.New(pluginmanager.Options{
DB: newGatewayTestPluginDB(t),
ArtifactRoot: t.TempDir(),
Adapter: gatewayTestPluginAdapter{initHook: func(gateway *pluginmanager.Gateway) error {
return api.RegisterHookHandler(gateway, api.HookRouteResolve,
func(api.RouteResolveRequest) bool { return true },
func(req api.RouteResolveRequest) (api.RouteDecision, error) {
if req.Host == "override.example" {
return api.RouteDecision{Action: api.RouteDecisionOverride, Upstream: "override-upstream:25565", CacheTTL: time.Minute}, nil
}
return api.RouteDecision{Action: api.RouteDecisionPass}, nil
})
}},
})
artifact := uploadGatewayTestArtifactWithManifest(t, pluginsManager, "route-plugin", func(manifest *pluginmanager.Manifest) {
manifest.ExtensionPoints = []pluginmanager.ExtensionPoint{{Type: "provider", Key: pluginmanager.ExtensionRouteResolve}}
manifest.Capabilities = json.RawMessage(`{"extension_points":["route.resolve/v1"]}`)
})
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "route-plugin", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil {
t.Fatalf("SetDesired() error = %v", err)
}
if _, err := pluginsManager.Enable(context.Background(), "admin", "route-plugin"); err != nil {
t.Fatalf("Enable() error = %v", err)
}
registerGatewayUpstreamHook(t, func(net.Conn, string) bool { return true }, func(_ net.Conn, host string) (net.Conn, error) {
dialed = append(dialed, host)
return newGatewayTestConn(nil), nil
})
handleRequest(newGatewayTestConn(gatewayTestPacket("override.example")))
handleRequest(newGatewayTestConn(gatewayTestPacket("fallback.example")))
if len(dialed) != 2 || dialed[0] != "override-upstream:25565" || dialed[1] != "fallback-upstream:25565" {
t.Fatalf("dialed = %+v, want override then sqlite fallback", dialed)
}
}
func TestHandleRequestStatusPingPluginRespondsPerHost(t *testing.T) {
defer saveGatewayState(t)()
pluginsManager = pluginmanager.New(pluginmanager.Options{
DB: newGatewayTestPluginDB(t),
ArtifactRoot: t.TempDir(),
Adapter: gatewayTestPluginAdapter{initHook: func(gateway *pluginmanager.Gateway) error {
return api.RegisterHookHandler(gateway, api.HookStatusPing,
func(api.StatusPingRequest) bool { return true },
func(req api.StatusPingRequest) (api.StatusPingResponse, error) {
return api.StatusPingResponse{MOTD: "hello " + req.Host, VersionText: "phase7", MaxPlayers: 100}, nil
})
}},
})
artifact := uploadGatewayTestArtifactWithManifest(t, pluginsManager, "status-plugin", func(manifest *pluginmanager.Manifest) {
manifest.ExtensionPoints = []pluginmanager.ExtensionPoint{{Type: "hook", Key: pluginmanager.ExtensionStatusPing}}
manifest.Capabilities = json.RawMessage(`{"extension_points":["status.ping/v1"]}`)
})
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "status-plugin", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil {
t.Fatalf("SetDesired() error = %v", err)
}
if _, err := pluginsManager.Enable(context.Background(), "admin", "status-plugin"); err != nil {
t.Fatalf("Enable() error = %v", err)
}
source := newGatewayTestConn(gatewayTestPacket("status.example", 0x63, 0x01))
handleRequest(source)
if got := source.writeBuf.String(); !strings.Contains(got, "hello status.example") {
t.Fatalf("status response = %q, want host MOTD", got)
}
}
func TestHandleRequestRecoversAndClosesConnection(t *testing.T) {
defer saveGatewayState(t)()

View File

@@ -10,6 +10,7 @@ import (
"sync"
"github.com/rs/zerolog/log"
"github.com/tursom/mc-gateway/internal/pluginmanager"
"github.com/tursom/mc-gateway/internal/upstreamtarget"
"github.com/tursom/mc-gateway/plugin/api"
"github.com/tursom/mc-gateway/protocol"
@@ -90,6 +91,22 @@ func handleRequest(conn net.Conn) {
}
func mapToHost(conn net.Conn) net.Conn {
if pluginsManager != nil {
transport, _, _ := connectionIngress(conn)
filter, err := pluginsManager.FilterConnection(context.Background(), api.ConnectionFilterRequest{
SourceAddr: conn.RemoteAddr().String(),
Transport: transport,
})
if err != nil {
log.Err(err).Str("client", conn.RemoteAddr().String()).Msg("connection filter failed")
return nil
}
if !filter.Allowed {
log.Info().Str("client", conn.RemoteAddr().String()).Str("plugin", filter.PluginID).Str("reason", filter.Reason).Msg("connection rejected by filter")
return nil
}
}
buf := getProxyBuffer()
defer putProxyBuffer(buf)
@@ -116,12 +133,44 @@ func mapToHost(conn net.Conn) net.Conn {
return nil
}
host, ok := lookupRoute(handshake.ServerHost)
if host == "" {
if pluginsManager != nil {
filter, err := pluginsManager.FilterHandshake(context.Background(), api.HandshakeFilterRequest{
SourceAddr: conn.RemoteAddr().String(),
ServerHost: handshake.ServerHost,
RawServerHost: handshake.RawServerHost,
ProtocolVersion: handshake.ProtocolVersion,
NextState: handshake.NextState,
})
if err != nil {
log.Err(err).Str("client", conn.RemoteAddr().String()).Str("host", handshake.ServerHost).Msg("handshake filter failed")
return nil
}
if !filter.Allowed {
log.Info().Str("client", conn.RemoteAddr().String()).Str("host", handshake.ServerHost).Str("plugin", filter.PluginID).Str("reason", filter.Reason).Msg("handshake rejected by filter")
return nil
}
if filter.RewriteHost != "" && filter.RewriteHost != handshake.ServerHost {
initialData = protocol.ReplaceMcHost(initialData, filter.RewriteHost)
handshake = protocol.ParseHandshake(initialData)
}
}
if handshake.NextState == 1 {
if handled := handleStatusPing(conn, handshake); handled {
return nil
}
}
routeResult := resolveGatewayRoute(conn, handshake)
host := routeResult.Decision.Upstream
ok := routeResult.Source != "fallback_miss"
if routeResult.Decision.Action == api.RouteDecisionReject || host == "" {
gatewayMetrics.RouteMiss()
log.Err(errEmptyBuffer).
Str("client", conn.RemoteAddr().String()).
Str("host", handshake.ServerHost).
Str("route_source", routeResult.Source).
Str("route_action", routeResult.Decision.Action).
Msg("failed to route host")
return nil
}
@@ -205,6 +254,88 @@ func mapToHost(conn net.Conn) net.Conn {
return client
}
func resolveGatewayRoute(conn net.Conn, handshake protocol.Handshake) pluginmanager.RouteResolveResult {
upstream, hit := lookupRoute(handshake.ServerHost)
req := api.RouteResolveRequest{
Host: handshake.ServerHost,
RawServerHost: handshake.RawServerHost,
SourceAddr: conn.RemoteAddr().String(),
ProtocolVersion: handshake.ProtocolVersion,
NextState: handshake.NextState,
FallbackUpstream: upstream,
FallbackHit: hit,
Handshake: api.UpstreamHandshakeRef{
ServerHost: handshake.ServerHost,
RawServerHost: handshake.RawServerHost,
ProtocolVersion: handshake.ProtocolVersion,
NextState: handshake.NextState,
},
}
if pluginsManager != nil {
result, err := pluginsManager.ResolveRoute(context.Background(), req, func(req api.RouteResolveRequest) (string, bool) {
return lookupRoute(req.Host)
})
if err == nil {
return result
}
log.Err(err).Str("client", conn.RemoteAddr().String()).Str("host", handshake.ServerHost).Msg("route resolver failed")
}
action := api.RouteDecisionFallback
source := "sqlite_fallback"
if upstream == "" {
action = api.RouteDecisionReject
source = "fallback_miss"
}
return pluginmanager.RouteResolveResult{
Decision: api.RouteDecision{Action: action, Upstream: upstream, ProviderID: "sqlite", Reason: "sqlite route snapshot fallback"},
Source: source,
}
}
func handleStatusPing(conn net.Conn, handshake protocol.Handshake) bool {
if pluginsManager == nil {
return false
}
result, err := pluginsManager.StatusPing(context.Background(), api.StatusPingRequest{
Host: handshake.ServerHost,
RawServerHost: handshake.RawServerHost,
SourceAddr: conn.RemoteAddr().String(),
ProtocolVersion: handshake.ProtocolVersion,
})
if err != nil || !result.Handled {
if err != nil {
log.Err(err).Str("host", handshake.ServerHost).Msg("status ping plugin failed")
}
return false
}
payload := map[string]any{
"description": map[string]any{"text": result.Response.MOTD},
"players": map[string]any{
"online": result.Response.OnlinePlayers,
"max": result.Response.MaxPlayers,
},
"version": map[string]any{
"name": result.Response.VersionText,
"protocol": result.Response.ProtocolVersion,
},
}
if result.Response.Favicon != "" {
payload["favicon"] = result.Response.Favicon
}
if result.Response.Maintenance {
payload["maintenance"] = map[string]any{"window": result.Response.MaintenanceWindow}
}
packet, err := protocol.StatusResponsePacket(payload)
if err != nil {
log.Err(err).Str("host", handshake.ServerHost).Msg("failed to build status response")
return true
}
if err := writeAll(conn, packet); err != nil {
log.Err(err).Str("host", handshake.ServerHost).Msg("failed to write status response")
}
return true
}
func newUpstreamConnectRequest(conn net.Conn, upstream string, handshake protocol.Handshake, initialData []byte, routeHit bool) api.UpstreamConnectRequest {
target := upstreamtarget.Parse(upstream)
transport, serviceName, listenerPort := connectionIngress(conn)

View File

@@ -227,7 +227,8 @@ func TestMapToHostClosesUpstreamWhenInitialWriteFails(t *testing.T) {
}
type gatewayTestPluginAdapter struct {
handler api.UpstreamConnectHandler
handler api.UpstreamConnectHandler
initHook func(*pluginmanager.Gateway) error
}
func (a gatewayTestPluginAdapter) Load(_ context.Context, _ pluginmanager.ArtifactRecord, _ pluginmanager.PluginRecord, gateway *pluginmanager.Gateway) (api.Plugin, error) {
@@ -237,12 +238,16 @@ func (a gatewayTestPluginAdapter) Load(_ context.Context, _ pluginmanager.Artifa
return nil, api.ErrPass
}
}
if err := api.RegisterHookHandler(
gateway,
api.HookUpstreamConnect,
func(api.UpstreamConnectRequest) bool { return true },
handler,
); err != nil {
if a.initHook == nil {
if err := api.RegisterHookHandler(
gateway,
api.HookUpstreamConnect,
func(api.UpstreamConnectRequest) bool { return true },
handler,
); err != nil {
return nil, err
}
} else if err := a.initHook(gateway); err != nil {
return nil, err
}
return &gatewayPluginStub{}, nil
@@ -279,6 +284,33 @@ func uploadGatewayTestArtifactWithCapabilities(t *testing.T, manager *pluginmana
return artifact
}
func uploadGatewayTestArtifactWithManifest(t *testing.T, manager *pluginmanager.Manager, pluginID string, mutate func(*pluginmanager.Manifest)) pluginmanager.ArtifactRecord {
t.Helper()
var manifest pluginmanager.Manifest
if err := json.Unmarshal(gatewayTestManifest(t, pluginID), &manifest); err != nil {
t.Fatalf("Unmarshal manifest error = %v", err)
}
if mutate != nil {
mutate(&manifest)
}
data, err := json.Marshal(manifest)
if err != nil {
t.Fatalf("Marshal manifest error = %v", err)
}
artifact, err := manager.UploadArtifact(context.Background(), pluginmanager.ArtifactUpload{
SourcePath: writeGatewayTestMCGPEntries(t, map[string][]byte{
"manifest.json": data,
"plugin.so": []byte("fake plugin bytes " + pluginID),
}),
FileName: pluginID + ".mcgp",
Actor: "admin",
})
if err != nil {
t.Fatalf("UploadArtifact() error = %v", err)
}
return artifact
}
func approveGatewayPluginGovernanceForTest(t *testing.T, pluginID, artifactID string) {
t.Helper()
if _, err := pluginsManager.CreateReview(context.Background(), "admin", pluginID, pluginmanager.GovernanceReviewRequest{

View File

@@ -114,3 +114,10 @@
- status 插件 disable 后恢复默认 status。
- event subscriber disable 后只停止外部投递,不删除本地审计。
- rule 插件冲突时通过 priority/scope 修复或禁用。
## 实现说明
- Route/status/middleware/provider/event subscriber 仍复用插件 `Gateway.Hook` 注册模型,新增 typed SDK 结构保持和 `upstream.connect/v1` 一致。
- 官方 rule/policy 以内置官方插件 `official.rule-policy` 提供,管理员启用后通过插件配置完成 host rewrite、source CIDR allow/deny、simple rate limit、maintenance mode 和 upstream rewrite。
- Admin auth provider 当前作为 provider registry 能力预留和展示,不进入 MC 连接路径,也不替代本地 admin break-glass 登录。
- Route provider 失败时优先使用 provider cache未命中时回退到 SQLite route snapshot。

View File

@@ -0,0 +1,10 @@
# Extension Ecosystem Example
This example demonstrates phase 7 extension points:
- `route.resolve/v1` returns `override`, `reject`, or `pass`.
- `status.ping/v1` returns host-specific MOTD text.
- `event.subscriber/v1` receives asynchronous plugin events.
- `admin.auth.provider/v1` registers an unavailable external provider while preserving local admin fallback.
It is intended as a conformance fixture and source example for plugin authors.

View File

@@ -0,0 +1,8 @@
{
"route_decisions": ["override", "fallback", "reject", "pass"],
"status_hosts": ["blue.example", "red.example"],
"event_delivery": ["best_effort", "at_least_once", "dead_letter"],
"provider_registry": ["singleton", "priority", "fallback", "admin.auth.provider/v1"],
"default_route_must_survive_bad_rule_config": true,
"local_admin_break_glass": true
}

View File

@@ -0,0 +1,35 @@
{
"schema_version": "mc-gateway.plugin/v1",
"id": "extension-ecosystem-example",
"name": "Extension Ecosystem Example",
"version": "0.1.0",
"description": "Example fixture for route, status, subscriber and provider extension points.",
"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.0",
"go_os": "linux",
"go_arch": "amd64",
"extension_points": [
{ "type": "provider", "key": "route.resolve/v1" },
{ "type": "hook", "key": "status.ping/v1" },
{ "type": "event", "key": "event.subscriber/v1" },
{ "type": "provider", "key": "admin.auth.provider/v1" }
],
"capabilities": {
"extension_points": ["route.resolve/v1", "status.ping/v1", "event.subscriber/v1", "admin.auth.provider/v1"],
"route": { "cache_ttl_ms": 60000 },
"status": { "hosts": ["blue.example", "red.example"] },
"event_subscriber": { "mode": "at_least_once", "max_retry": 3 },
"providers": [{ "type": "admin.auth.provider/v1", "name": "external-identity", "fallback": true }]
},
"runtime_limits": { "handler_timeout_ms": 1000 },
"config_schema": { "type": "object" }
}

View File

@@ -358,6 +358,64 @@ CREATE TABLE IF NOT EXISTS plugin_diagnostics (
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS plugin_service_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
desired_mode TEXT NOT NULL DEFAULT 'in-process',
active_mode TEXT NOT NULL DEFAULT 'in-process',
applied_at INTEGER NOT NULL DEFAULT 0,
live_migration TEXT NOT NULL DEFAULT 'drain-only',
last_error TEXT NOT NULL DEFAULT '',
updated_by TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS plugin_repository_imports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repository_type TEXT NOT NULL,
index_path TEXT NOT NULL DEFAULT '',
repository_name TEXT NOT NULL DEFAULT '',
candidate_id TEXT NOT NULL DEFAULT '',
plugin_id TEXT NOT NULL DEFAULT '',
version TEXT NOT NULL DEFAULT '',
artifact_id TEXT NOT NULL DEFAULT '',
package_sha256 TEXT NOT NULL DEFAULT '',
trust_policy TEXT NOT NULL DEFAULT '',
admission_json TEXT NOT NULL DEFAULT '{}',
imported_by TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS plugin_supply_chain_assessments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plugin_id TEXT NOT NULL,
artifact_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'allowed',
issues_json TEXT NOT NULL DEFAULT '[]',
signature_json TEXT NOT NULL DEFAULT '{}',
sbom_json TEXT NOT NULL DEFAULT '{}',
license_json TEXT NOT NULL DEFAULT '{}',
advisory_json TEXT NOT NULL DEFAULT '{}',
metadata_json TEXT NOT NULL DEFAULT '{}',
created_by TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS plugin_instrumentation (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
version TEXT NOT NULL DEFAULT '',
profile TEXT NOT NULL DEFAULT '',
generated_diff_hash TEXT NOT NULL DEFAULT '',
provenance_json TEXT NOT NULL DEFAULT '{}',
conformance_json TEXT NOT NULL DEFAULT '{}',
benchmark_json TEXT NOT NULL DEFAULT '{}',
smoke_json TEXT NOT NULL DEFAULT '{}',
runbook_rollback TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'available',
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);
@@ -379,6 +437,11 @@ CREATE INDEX IF NOT EXISTS idx_plugin_traces_lookup ON plugin_traces(plugin_id,
CREATE INDEX IF NOT EXISTS idx_plugin_data_expires ON plugin_data(expires_at);
CREATE INDEX IF NOT EXISTS idx_plugin_files_expires ON plugin_files(expires_at);
CREATE INDEX IF NOT EXISTS idx_plugin_diagnostics_lookup ON plugin_diagnostics(plugin_id, created_at);
CREATE INDEX IF NOT EXISTS idx_plugin_repository_imports_artifact ON plugin_repository_imports(artifact_id, created_at);
CREATE INDEX IF NOT EXISTS idx_plugin_supply_chain_lookup ON plugin_supply_chain_assessments(plugin_id, artifact_id, created_at);
CREATE INDEX IF NOT EXISTS idx_plugin_instrumentation_lookup ON plugin_instrumentation(name, created_at);
INSERT OR IGNORE INTO plugin_service_state(id, desired_mode, active_mode, applied_at, live_migration, updated_by, updated_at)
VALUES (1, 'in-process', 'in-process', strftime('%s','now'), 'drain-only', 'system', strftime('%s','now'));
INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (1, strftime('%s','now'));
`
if _, err := db.Exec(schema); err != nil {
@@ -393,6 +456,12 @@ INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (1, strftime
if err := ensureColumn(db, "plugin_secrets", "hot_reload", "INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
}
if err := ensureColumn(db, "plugin_service_state", "live_migration", "TEXT NOT NULL DEFAULT 'drain-only'"); err != nil {
return err
}
if err := ensureColumn(db, "plugin_service_state", "last_error", "TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
return nil
}

View File

@@ -29,25 +29,29 @@ type APIHandlers struct {
AuditLogs http.HandlerFunc
PluginArtifacts http.HandlerFunc
PluginArtifact SegmentHandlerFunc
PluginSources http.HandlerFunc
PluginBuilds http.HandlerFunc
PluginBuild SegmentHandlerFunc
PluginGC http.HandlerFunc
PluginsList http.HandlerFunc
PluginItem SegmentHandlerFunc
PluginAction SegmentHandlerFunc
PluginConfig SegmentHandlerFunc
PluginSecrets SegmentHandlerFunc
PluginRollback SegmentHandlerFunc
PluginOperations SegmentHandlerFunc
PluginOperationsGC http.HandlerFunc
PluginDraining SegmentHandlerFunc
PluginDispatch http.HandlerFunc
PluginGovernance SegmentHandlerFunc
PluginAdvisories http.HandlerFunc
PluginDiagnostics SegmentHandlerFunc
PluginArtifacts http.HandlerFunc
PluginArtifact SegmentHandlerFunc
PluginSources http.HandlerFunc
PluginBuilds http.HandlerFunc
PluginBuild SegmentHandlerFunc
PluginGC http.HandlerFunc
PluginsList http.HandlerFunc
PluginItem SegmentHandlerFunc
PluginAction SegmentHandlerFunc
PluginConfig SegmentHandlerFunc
PluginSecrets SegmentHandlerFunc
PluginRollback SegmentHandlerFunc
PluginOperations SegmentHandlerFunc
PluginOperationsGC http.HandlerFunc
PluginDraining SegmentHandlerFunc
PluginDispatch http.HandlerFunc
PluginGovernance SegmentHandlerFunc
PluginAdvisories http.HandlerFunc
PluginDiagnostics SegmentHandlerFunc
PluginService http.HandlerFunc
PluginRepositories http.HandlerFunc
PluginSupplyChain http.HandlerFunc
PluginInstrumentation http.HandlerFunc
}
func NewAPIHandler(prefix string, handlers APIHandlers) http.HandlerFunc {
@@ -105,10 +109,18 @@ func NewAPIHandler(prefix string, handlers APIHandlers) http.HandlerFunc {
callHandler(w, r, handlers.PluginOperationsGC)
case path == "/plugins" && r.Method == http.MethodGet:
callHandler(w, r, handlers.PluginsList)
case path == "/plugins/dispatch-plan" && r.Method == http.MethodGet:
case path == "/plugins/dispatch-plan" && (r.Method == http.MethodGet || r.Method == http.MethodPost):
callHandler(w, r, handlers.PluginDispatch)
case path == "/plugin-advisories" && (r.Method == http.MethodGet || r.Method == http.MethodPost):
callHandler(w, r, handlers.PluginAdvisories)
case path == "/plugin-service" && (r.Method == http.MethodGet || r.Method == http.MethodPut || r.Method == http.MethodPost):
callHandler(w, r, handlers.PluginService)
case path == "/plugin-repositories/imports" && (r.Method == http.MethodGet || r.Method == http.MethodPost):
callHandler(w, r, handlers.PluginRepositories)
case path == "/plugin-supply-chain" && (r.Method == http.MethodGet || r.Method == http.MethodPost):
callHandler(w, r, handlers.PluginSupplyChain)
case path == "/plugin-instrumentation" && (r.Method == http.MethodGet || r.Method == http.MethodPost):
callHandler(w, r, handlers.PluginInstrumentation)
case strings.HasPrefix(path, "/plugins/"):
pluginPath := strings.TrimPrefix(path, "/plugins/")
if strings.Contains(pluginPath, "/governance/") || strings.HasSuffix(pluginPath, "/governance") {

View File

@@ -53,6 +53,10 @@ func TestNewAPIHandlerRoutesRequests(t *testing.T) {
{name: "plugin draining force close", method: http.MethodPost, path: "/admin/api/plugins/mc-auth-proxy/draining/force-close", wantCall: "plugin_draining", wantSegment: "mc-auth-proxy"},
{name: "plugin dispatch", method: http.MethodGet, path: "/admin/api/plugins/dispatch-plan", wantCall: "plugin_dispatch"},
{name: "plugin advisories", method: http.MethodGet, path: "/admin/api/plugin-advisories", wantCall: "plugin_advisories"},
{name: "plugin service", method: http.MethodGet, path: "/admin/api/plugin-service", wantCall: "plugin_service"},
{name: "plugin repositories", method: http.MethodGet, path: "/admin/api/plugin-repositories/imports", wantCall: "plugin_repositories"},
{name: "plugin supply chain", method: http.MethodGet, path: "/admin/api/plugin-supply-chain", wantCall: "plugin_supply_chain"},
{name: "plugin instrumentation", method: http.MethodGet, path: "/admin/api/plugin-instrumentation", wantCall: "plugin_instrumentation"},
}
for _, tt := range tests {
@@ -80,25 +84,29 @@ func TestNewAPIHandlerRoutesRequests(t *testing.T) {
AuditLogs: recordCall(&gotCall, "audit_logs"),
PluginArtifacts: recordCall(&gotCall, "plugin_artifacts"),
PluginArtifact: recordSegmentCall(&gotCall, &gotSegment, "plugin_artifact"),
PluginSources: recordCall(&gotCall, "plugin_sources"),
PluginBuilds: recordCall(&gotCall, "plugin_builds"),
PluginBuild: recordSegmentCall(&gotCall, &gotSegment, "plugin_build"),
PluginGC: recordCall(&gotCall, "plugin_gc"),
PluginOperationsGC: recordCall(&gotCall, "plugin_operations_gc"),
PluginsList: recordCall(&gotCall, "plugins_list"),
PluginItem: recordSegmentCall(&gotCall, &gotSegment, "plugin_item"),
PluginAction: recordSegmentCall(&gotCall, &gotSegment, "plugin_action"),
PluginConfig: recordSegmentCall(&gotCall, &gotSegment, "plugin_config"),
PluginSecrets: recordSegmentCall(&gotCall, &gotSegment, "plugin_secrets"),
PluginRollback: recordSegmentCall(&gotCall, &gotSegment, "plugin_rollback"),
PluginOperations: recordSegmentCall(&gotCall, &gotSegment, "plugin_operations"),
PluginDraining: recordSegmentCall(&gotCall, &gotSegment, "plugin_draining"),
PluginDispatch: recordCall(&gotCall, "plugin_dispatch"),
PluginGovernance: recordSegmentCall(&gotCall, &gotSegment, "plugin_governance"),
PluginAdvisories: recordCall(&gotCall, "plugin_advisories"),
PluginDiagnostics: recordSegmentCall(&gotCall, &gotSegment, "plugin_diagnostics"),
PluginArtifacts: recordCall(&gotCall, "plugin_artifacts"),
PluginArtifact: recordSegmentCall(&gotCall, &gotSegment, "plugin_artifact"),
PluginSources: recordCall(&gotCall, "plugin_sources"),
PluginBuilds: recordCall(&gotCall, "plugin_builds"),
PluginBuild: recordSegmentCall(&gotCall, &gotSegment, "plugin_build"),
PluginGC: recordCall(&gotCall, "plugin_gc"),
PluginOperationsGC: recordCall(&gotCall, "plugin_operations_gc"),
PluginsList: recordCall(&gotCall, "plugins_list"),
PluginItem: recordSegmentCall(&gotCall, &gotSegment, "plugin_item"),
PluginAction: recordSegmentCall(&gotCall, &gotSegment, "plugin_action"),
PluginConfig: recordSegmentCall(&gotCall, &gotSegment, "plugin_config"),
PluginSecrets: recordSegmentCall(&gotCall, &gotSegment, "plugin_secrets"),
PluginRollback: recordSegmentCall(&gotCall, &gotSegment, "plugin_rollback"),
PluginOperations: recordSegmentCall(&gotCall, &gotSegment, "plugin_operations"),
PluginDraining: recordSegmentCall(&gotCall, &gotSegment, "plugin_draining"),
PluginDispatch: recordCall(&gotCall, "plugin_dispatch"),
PluginGovernance: recordSegmentCall(&gotCall, &gotSegment, "plugin_governance"),
PluginAdvisories: recordCall(&gotCall, "plugin_advisories"),
PluginDiagnostics: recordSegmentCall(&gotCall, &gotSegment, "plugin_diagnostics"),
PluginService: recordCall(&gotCall, "plugin_service"),
PluginRepositories: recordCall(&gotCall, "plugin_repositories"),
PluginSupplyChain: recordCall(&gotCall, "plugin_supply_chain"),
PluginInstrumentation: recordCall(&gotCall, "plugin_instrumentation"),
})
resp := httptest.NewRecorder()

View File

@@ -64,3 +64,22 @@ type PluginRollbackRequest struct {
SnapshotID int64 `json:"snapshot_id"`
FullDesired bool `json:"full_desired"`
}
type PluginServiceRequest struct {
DesiredMode string `json:"desired_mode"`
}
type PluginRepositoryImportRequest struct {
RepositoryType string `json:"repository_type"`
IndexPath string `json:"index_path"`
ArtifactID string `json:"artifact_id"`
PluginID string `json:"plugin_id"`
Version string `json:"version"`
TrustPolicy string `json:"trust_policy"`
}
type PluginSupplyChainRequest struct {
PluginID string `json:"plugin_id"`
ArtifactID string `json:"artifact_id"`
Metadata map[string]any `json:"metadata"`
}

View File

@@ -406,7 +406,13 @@ func capabilitiesSummaryJSON(raw json.RawMessage) ([]byte, error) {
summary.Raw = append(json.RawMessage(nil), raw...)
var caps struct {
UpstreamConnect UpstreamConnectCapability `json:"upstream_connect"`
Route RouteCapability `json:"route"`
Status StatusCapability `json:"status"`
Middleware MiddlewareCapability `json:"middleware"`
Providers []ProviderCapability `json:"providers"`
EventSubscriber EventSubscriberCapability `json:"event_subscriber"`
Minecraft *MinecraftCapability `json:"minecraft"`
Runtime RuntimeCapability `json:"runtime"`
}
if err := json.Unmarshal(raw, &caps); err != nil {
return nil, fmt.Errorf("invalid capabilities: %w", err)
@@ -414,6 +420,13 @@ func capabilitiesSummaryJSON(raw json.RawMessage) ([]byte, error) {
if caps.UpstreamConnect.Mode != "" {
summary.UpstreamConnect.Mode = caps.UpstreamConnect.Mode
}
summary.Route = caps.Route
summary.Status = caps.Status
summary.Middleware = caps.Middleware
summary.Providers = append([]ProviderCapability(nil), caps.Providers...)
summary.EventSubscriber = caps.EventSubscriber
summary.Runtime = caps.Runtime
summary.Runtime.RequiredFeatures = append(summary.Runtime.RequiredFeatures, stringSlice(jsonObjectFromRaw(string(raw), "required_features"))...)
if caps.Minecraft != nil {
summary.Minecraft = caps.Minecraft
if summary.Minecraft.UnsupportedPolicy == "" {
@@ -455,35 +468,37 @@ func validateManifest(manifest Manifest) error {
return errors.New("version is required")
case manifest.ArtifactType != ArtifactTypeBinary && manifest.ArtifactType != ArtifactTypeSource:
return fmt.Errorf("unsupported artifact_type %q", manifest.ArtifactType)
case manifest.Runtime.Type != RuntimeGoPlugin:
case manifest.Runtime.Type != RuntimeGoPlugin && manifest.Runtime.Type != RuntimeBuiltin && manifest.Runtime.Type != RuntimeSandbox && manifest.Runtime.Type != RuntimeWASM:
return fmt.Errorf("unsupported runtime.type %q", manifest.Runtime.Type)
case manifest.ArtifactType == ArtifactTypeBinary && manifest.Runtime.Entry != RuntimeEntry:
case manifest.ArtifactType == ArtifactTypeBinary && manifest.Runtime.Type == RuntimeGoPlugin && manifest.Runtime.Entry != RuntimeEntry:
return fmt.Errorf("unsupported runtime.entry %q", manifest.Runtime.Entry)
case manifest.ArtifactType == ArtifactTypeBinary && manifest.Runtime.Type == RuntimeWASM && manifest.Runtime.Entry != RuntimeWASMEntry:
return fmt.Errorf("unsupported runtime.entry %q", manifest.Runtime.Entry)
case manifest.ArtifactType == ArtifactTypeSource && rawSourceBuildEntry(manifest) == "":
return errors.New("build.entry is required for source artifacts")
case manifest.APIVersion != APIVersion:
return fmt.Errorf("unsupported api_version %q", manifest.APIVersion)
case manifest.ArtifactType == ArtifactTypeBinary && manifest.GoVersion == "":
case manifest.ArtifactType == ArtifactTypeBinary && manifest.Runtime.Type == RuntimeGoPlugin && manifest.GoVersion == "":
return errors.New("go_version is required")
case manifest.ArtifactType == ArtifactTypeBinary && manifest.GOOS == "":
case manifest.ArtifactType == ArtifactTypeBinary && manifest.Runtime.Type == RuntimeGoPlugin && manifest.GOOS == "":
return errors.New("go_os is required")
case manifest.ArtifactType == ArtifactTypeBinary && manifest.GOARCH == "":
case manifest.ArtifactType == ArtifactTypeBinary && manifest.Runtime.Type == RuntimeGoPlugin && manifest.GOARCH == "":
return errors.New("go_arch is required")
}
if manifest.ArtifactType == ArtifactTypeBinary && manifest.GOOS != "" && manifest.GOOS != runtime.GOOS {
if manifest.ArtifactType == ArtifactTypeBinary && manifest.Runtime.Type == RuntimeGoPlugin && manifest.GOOS != "" && manifest.GOOS != runtime.GOOS {
return fmt.Errorf("go_os %q does not match gateway %q", manifest.GOOS, runtime.GOOS)
}
if manifest.ArtifactType == ArtifactTypeBinary && manifest.GOARCH != "" && manifest.GOARCH != runtime.GOARCH {
if manifest.ArtifactType == ArtifactTypeBinary && manifest.Runtime.Type == RuntimeGoPlugin && manifest.GOARCH != "" && manifest.GOARCH != runtime.GOARCH {
return fmt.Errorf("go_arch %q does not match gateway %q", manifest.GOARCH, runtime.GOARCH)
}
found := false
for _, ep := range manifest.ExtensionPoints {
if ep.Type == "hook" && ep.Key == ExtensionUpstreamConnect {
if supportedExtensionPoint(ep.Key) {
found = true
}
}
if !found {
return fmt.Errorf("extension point %q is required", ExtensionUpstreamConnect)
return errors.New("at least one supported extension point is required")
}
seenSecrets := make(map[string]bool, len(manifest.Secrets))
for _, secret := range manifest.Secrets {
@@ -510,6 +525,18 @@ func validateManifest(manifest Manifest) error {
return nil
}
func supportedExtensionPoint(key string) bool {
switch key {
case ExtensionUpstreamConnect, ExtensionRouteResolve, ExtensionRouteResolver, ExtensionStatusPing,
ExtensionConnectionFilter, ExtensionHandshakeFilter, ExtensionEventSubscriber,
ExtensionProvider, ExtensionAuthProvider, ExtensionAdminAuthProvider,
ExtensionRuleEvaluate, ExtensionConfigValidate:
return true
default:
return false
}
}
func validateNamedSpecs(kind string, names []string) error {
seen := make(map[string]bool, len(names))
for _, name := range names {

View File

@@ -0,0 +1,861 @@
package pluginmanager
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"sync/atomic"
"time"
"github.com/tursom/mc-gateway/plugin/api"
)
type dispatchState struct {
upstreams []*upstreamHandler
routes []*routeHandler
statuses []*statusHandler
middleware []*middlewareHandler
subscribers []*subscriberHandler
providers []ProviderSummary
}
type routeHandler struct {
pluginID string
artifactID string
priority int
handlerID string
extensionPoint string
timeout time.Duration
accept api.RouteResolveAcceptor
handle api.RouteResolveHandler
calls atomic.Uint64
errors atomic.Uint64
panics atomic.Uint64
timeouts atomic.Uint64
blocked atomic.Uint64
durationCount atomic.Uint64
durationSumMS atomic.Uint64
durationMaxMS atomic.Uint64
}
type statusHandler struct {
pluginID string
artifactID string
priority int
handlerID string
extensionPoint string
timeout time.Duration
accept api.StatusPingAcceptor
handle api.StatusPingHandler
calls atomic.Uint64
errors atomic.Uint64
panics atomic.Uint64
timeouts atomic.Uint64
blocked atomic.Uint64
durationCount atomic.Uint64
durationSumMS atomic.Uint64
durationMaxMS atomic.Uint64
}
type middlewareHandler struct {
pluginID string
artifactID string
priority int
handlerID string
extensionPoint string
timeout time.Duration
failPolicy string
connectionAccept api.ConnectionFilterAcceptor
connectionHandle api.ConnectionFilterHandler
handshakeAccept api.HandshakeFilterAcceptor
handshakeHandle api.HandshakeFilterHandler
calls atomic.Uint64
errors atomic.Uint64
panics atomic.Uint64
timeouts atomic.Uint64
blocked atomic.Uint64
durationCount atomic.Uint64
durationSumMS atomic.Uint64
durationMaxMS atomic.Uint64
}
type subscriberHandler struct {
pluginID string
artifactID string
priority int
handlerID string
extensionPoint string
timeout time.Duration
mode string
maxRetry int
accept api.EventSubscriberAcceptor
handle api.EventSubscriberHandler
calls atomic.Uint64
errors atomic.Uint64
panics atomic.Uint64
timeouts atomic.Uint64
blocked atomic.Uint64
durationCount atomic.Uint64
durationSumMS atomic.Uint64
durationMaxMS atomic.Uint64
}
type routeCacheEntry struct {
summary RouteDecisionSummary
decision api.RouteDecision
}
type RouteResolveResult struct {
Decision api.RouteDecision `json:"decision"`
Source string `json:"source"`
Summary RouteDecisionSummary `json:"summary"`
}
type StatusPingResult struct {
Handled bool `json:"handled"`
Response api.StatusPingResponse `json:"response"`
PluginID string `json:"plugin_id,omitempty"`
Source string `json:"source,omitempty"`
}
type ConnectionFilterResult struct {
Allowed bool `json:"allowed"`
PluginID string `json:"plugin_id,omitempty"`
Reason string `json:"reason,omitempty"`
}
type HandshakeFilterResult struct {
Allowed bool `json:"allowed"`
PluginID string `json:"plugin_id,omitempty"`
Reason string `json:"reason,omitempty"`
RewriteHost string `json:"rewrite_host,omitempty"`
}
func (e pluginExtensions) empty() bool {
return e.count() == 0
}
func (e pluginExtensions) count() int {
return len(e.routes) + len(e.statuses) + len(e.middleware) + len(e.subscribers) + len(e.providers)
}
func (loaded *loadedPlugin) dispatchSummaries() []DispatchHandlerSummary {
out := handlerSummaries(loaded.handlers)
out = append(out, routeHandlerSummaries(loaded.extensions.routes)...)
out = append(out, statusHandlerSummaries(loaded.extensions.statuses)...)
out = append(out, middlewareHandlerSummaries(loaded.extensions.middleware)...)
out = append(out, subscriberHandlerSummaries(loaded.extensions.subscribers)...)
return out
}
func (m *Manager) ResolveRoute(ctx context.Context, req api.RouteResolveRequest, fallback func(api.RouteResolveRequest) (string, bool)) (RouteResolveResult, error) {
if req.Context == nil {
req.Context = ctx
}
if fallback != nil && req.FallbackUpstream == "" {
upstream, ok := fallback(req)
req.FallbackUpstream = upstream
req.FallbackHit = ok
}
state := m.extensionState()
for _, handler := range state.routes {
accepted, err := handler.accepts(req)
if err != nil {
return RouteResolveResult{}, err
}
if !accepted {
continue
}
decision, err := handler.invoke(req)
if err != nil {
if errors.Is(err, api.ErrPass) {
continue
}
if cached, ok := m.cachedRoute(req.Host); ok {
cached.Source = "cache"
return RouteResolveResult{Decision: cachedDecision(cached), Source: "cache", Summary: cached}, nil
}
break
}
decision = normalizeRouteDecision(decision, handler.pluginID)
if decision.Action == api.RouteDecisionPass || decision.Action == "" {
continue
}
summary := routeDecisionSummary(req.Host, decision, "provider")
m.cacheRoute(req.Host, decision, summary)
return RouteResolveResult{Decision: decision, Source: "provider", Summary: summary}, nil
}
if cached, ok := m.cachedRoute(req.Host); ok && !req.Refresh {
cached.Source = "cache"
return RouteResolveResult{Decision: cachedDecision(cached), Source: "cache", Summary: cached}, nil
}
upstream := req.FallbackUpstream
source := "sqlite_fallback"
if upstream == "" {
source = "fallback_miss"
}
decision := api.RouteDecision{
Action: api.RouteDecisionFallback,
Upstream: upstream,
ProviderID: "sqlite",
Reason: "sqlite route snapshot fallback",
}
if upstream == "" {
decision.Action = api.RouteDecisionReject
decision.Reason = "no route provider decision and no sqlite fallback"
}
summary := routeDecisionSummary(req.Host, decision, source)
return RouteResolveResult{Decision: decision, Source: source, Summary: summary}, nil
}
func (m *Manager) RefreshRouteProviders(ctx context.Context, actor string) []RouteDecisionSummary {
hosts := make([]string, 0)
m.routeCacheMu.Lock()
for host := range m.routeCache {
hosts = append(hosts, host)
}
m.routeCache = make(map[string]routeCacheEntry)
m.routeCacheMu.Unlock()
sort.Strings(hosts)
_ = m.repo.RecordOperation(ctx, "", "", "route_provider_refresh", "succeeded", actor, "route provider cache refreshed", map[string]any{
"cleared_hosts": hosts,
})
return m.RouteCacheSnapshot()
}
func (m *Manager) ReplaySubscriberDeadLetters(ctx context.Context, actor string) uint64 {
count := m.operations.SubscriberDeadLetters()
_ = m.repo.RecordOperation(ctx, "", "", "event_subscriber_replay", "succeeded", actor, "event subscriber dead letter replay requested", map[string]any{
"dead_letters": count,
})
return count
}
func (m *Manager) DropSubscriberDeadLetters(ctx context.Context, actor string) uint64 {
count := m.operations.DropSubscriberDeadLetters()
_ = m.repo.RecordOperation(ctx, "", "", "event_subscriber_drop", "succeeded", actor, "event subscriber dead letters dropped", map[string]any{
"dead_letters": count,
})
return count
}
func (m *Manager) RouteCacheSnapshot() []RouteDecisionSummary {
m.routeCacheMu.Lock()
defer m.routeCacheMu.Unlock()
out := make([]RouteDecisionSummary, 0, len(m.routeCache))
now := time.Now().Unix()
for _, entry := range m.routeCache {
if entry.summary.ExpiresAt > 0 && entry.summary.ExpiresAt <= now {
continue
}
out = append(out, entry.summary)
}
sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host })
return out
}
func (m *Manager) StatusPing(ctx context.Context, req api.StatusPingRequest) (StatusPingResult, error) {
if req.Context == nil {
req.Context = ctx
}
for _, handler := range m.extensionState().statuses {
accepted, err := handler.accepts(req)
if err != nil {
return StatusPingResult{}, err
}
if !accepted {
continue
}
resp, err := handler.invoke(req)
if err != nil {
if errors.Is(err, api.ErrPass) {
continue
}
return StatusPingResult{}, err
}
return StatusPingResult{Handled: true, Response: resp, PluginID: handler.pluginID, Source: "plugin"}, nil
}
return StatusPingResult{}, nil
}
func (m *Manager) FilterConnection(ctx context.Context, req api.ConnectionFilterRequest) (ConnectionFilterResult, error) {
if req.Context == nil {
req.Context = ctx
}
for _, handler := range m.extensionState().middleware {
if handler.connectionHandle == nil {
continue
}
accepted, err := handler.acceptsConnection(req)
if err != nil {
if handler.failPolicy == api.FailPolicyClose {
return ConnectionFilterResult{Allowed: false, PluginID: handler.pluginID, Reason: err.Error()}, err
}
continue
}
if !accepted {
continue
}
decision, err := handler.invokeConnection(req)
if err != nil {
if handler.failPolicy == api.FailPolicyClose {
return ConnectionFilterResult{Allowed: false, PluginID: handler.pluginID, Reason: err.Error()}, err
}
continue
}
if decision.Reject || !decision.Allow {
handler.blocked.Add(1)
return ConnectionFilterResult{Allowed: false, PluginID: handler.pluginID, Reason: decision.Reason}, nil
}
}
return ConnectionFilterResult{Allowed: true}, nil
}
func (m *Manager) FilterHandshake(ctx context.Context, req api.HandshakeFilterRequest) (HandshakeFilterResult, error) {
if req.Context == nil {
req.Context = ctx
}
result := HandshakeFilterResult{Allowed: true}
for _, handler := range m.extensionState().middleware {
if handler.handshakeHandle == nil {
continue
}
accepted, err := handler.acceptsHandshake(req)
if err != nil {
if handler.failPolicy == api.FailPolicyClose {
return HandshakeFilterResult{Allowed: false, PluginID: handler.pluginID, Reason: err.Error()}, err
}
continue
}
if !accepted {
continue
}
decision, err := handler.invokeHandshake(req)
if err != nil {
if handler.failPolicy == api.FailPolicyClose {
return HandshakeFilterResult{Allowed: false, PluginID: handler.pluginID, Reason: err.Error()}, err
}
continue
}
if decision.Reject || !decision.Allow {
handler.blocked.Add(1)
return HandshakeFilterResult{Allowed: false, PluginID: handler.pluginID, Reason: decision.Reason}, nil
}
if decision.RewriteHost != "" {
result.RewriteHost = decision.RewriteHost
result.PluginID = handler.pluginID
req.ServerHost = decision.RewriteHost
}
}
return result, nil
}
func (m *Manager) extensionState() dispatchState {
value := m.extensionSnapshot.Load()
if state, ok := value.(dispatchState); ok {
return state
}
return dispatchState{}
}
func (m *Manager) currentExtensionsLocked() map[string]pluginExtensions {
current := make(map[string]pluginExtensions)
state := m.extensionState()
for _, handler := range state.routes {
ext := current[handler.pluginID]
ext.routes = append(ext.routes, handler)
current[handler.pluginID] = ext
}
for _, handler := range state.statuses {
ext := current[handler.pluginID]
ext.statuses = append(ext.statuses, handler)
current[handler.pluginID] = ext
}
for _, handler := range state.middleware {
ext := current[handler.pluginID]
ext.middleware = append(ext.middleware, handler)
current[handler.pluginID] = ext
}
for _, handler := range state.subscribers {
ext := current[handler.pluginID]
ext.subscribers = append(ext.subscribers, handler)
current[handler.pluginID] = ext
}
for _, provider := range state.providers {
ext := current[provider.PluginID]
ext.providers = append(ext.providers, provider)
current[provider.PluginID] = ext
}
return current
}
func (m *Manager) publishExtensionsLocked(byPlugin map[string]pluginExtensions) {
var state dispatchState
for _, ext := range byPlugin {
state.routes = append(state.routes, ext.routes...)
state.statuses = append(state.statuses, ext.statuses...)
state.middleware = append(state.middleware, ext.middleware...)
state.subscribers = append(state.subscribers, ext.subscribers...)
state.providers = append(state.providers, ext.providers...)
}
sortRouteHandlers(state.routes)
sortStatusHandlers(state.statuses)
sortMiddlewareHandlers(state.middleware)
sortSubscriberHandlers(state.subscribers)
sort.SliceStable(state.providers, func(i, j int) bool {
if state.providers[i].Type != state.providers[j].Type {
return state.providers[i].Type < state.providers[j].Type
}
if state.providers[i].Priority != state.providers[j].Priority {
return state.providers[i].Priority < state.providers[j].Priority
}
return state.providers[i].Name < state.providers[j].Name
})
m.extensionSnapshot.Store(state)
m.operations.SetSubscribers(state.subscribers)
}
func (m *Manager) removeExtensionsLocked(pluginID string) {
current := m.currentExtensionsLocked()
delete(current, pluginID)
m.publishExtensionsLocked(current)
}
func buildExtensions(pluginRecord PluginRecord, artifact ArtifactRecord, gateway *Gateway) pluginExtensions {
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
}
failPolicy := api.FailPolicyOpen
var summary CapabilitySummary
if err := json.Unmarshal([]byte(artifact.CapabilitiesSummaryJSON), &summary); err == nil && summary.Middleware.FailPolicy != "" {
failPolicy = summary.Middleware.FailPolicy
}
ext := pluginExtensions{}
if hook, ok := gateway.RouteResolveHandler(); ok {
ext.routes = append(ext.routes, &routeHandler{
pluginID: pluginRecord.ID, artifactID: artifact.ID, priority: pluginRecord.Priority,
handlerID: ExtensionRouteResolve, extensionPoint: ExtensionRouteResolve, timeout: timeout,
accept: hook.Acceptor(), handle: hook.Handler(),
})
}
if hook, ok := gateway.RouteResolverHandler(); ok {
ext.routes = append(ext.routes, &routeHandler{
pluginID: pluginRecord.ID, artifactID: artifact.ID, priority: pluginRecord.Priority,
handlerID: ExtensionRouteResolver, extensionPoint: ExtensionRouteResolver, timeout: timeout,
accept: hook.Acceptor(), handle: hook.Handler(),
})
}
if hook, ok := gateway.StatusPingHandler(); ok {
ext.statuses = append(ext.statuses, &statusHandler{
pluginID: pluginRecord.ID, artifactID: artifact.ID, priority: pluginRecord.Priority,
handlerID: ExtensionStatusPing, extensionPoint: ExtensionStatusPing, timeout: timeout,
accept: hook.Acceptor(), handle: hook.Handler(),
})
}
if hook, ok := gateway.ConnectionFilterHandler(); ok {
ext.middleware = append(ext.middleware, &middlewareHandler{
pluginID: pluginRecord.ID, artifactID: artifact.ID, priority: pluginRecord.Priority,
handlerID: ExtensionConnectionFilter, extensionPoint: ExtensionConnectionFilter, timeout: timeout, failPolicy: failPolicy,
connectionAccept: hook.Acceptor(), connectionHandle: hook.Handler(),
})
}
if hook, ok := gateway.HandshakeFilterHandler(); ok {
ext.middleware = append(ext.middleware, &middlewareHandler{
pluginID: pluginRecord.ID, artifactID: artifact.ID, priority: pluginRecord.Priority,
handlerID: ExtensionHandshakeFilter, extensionPoint: ExtensionHandshakeFilter, timeout: timeout, failPolicy: failPolicy,
handshakeAccept: hook.Acceptor(), handshakeHandle: hook.Handler(),
})
}
if hook, ok := gateway.EventSubscriberHandler(); ok {
mode := summary.EventSubscriber.Mode
if mode == "" {
mode = api.DeliveryBestEffort
}
maxRetry := summary.EventSubscriber.MaxRetry
if maxRetry <= 0 {
maxRetry = DefaultSubscriberMaxRetry
}
ext.subscribers = append(ext.subscribers, &subscriberHandler{
pluginID: pluginRecord.ID, artifactID: artifact.ID, priority: pluginRecord.Priority,
handlerID: ExtensionEventSubscriber, extensionPoint: ExtensionEventSubscriber, timeout: timeout,
mode: mode, maxRetry: maxRetry, accept: hook.Acceptor(), handle: hook.Handler(),
})
}
ext.providers = append(ext.providers, buildProviderSummaries(pluginRecord, artifact, gateway)...)
return ext
}
func buildProviderSummaries(pluginRecord PluginRecord, artifact ArtifactRecord, gateway *Gateway) []ProviderSummary {
var out []ProviderSummary
providerHook, providerOK := gateway.ProviderHandler()
authHook, authOK := gateway.AuthProviderHandler()
adminAuthHook, adminAuthOK := gateway.AdminAuthProviderHandler()
items := []struct {
key string
hook api.HookHandler[api.ProviderAcceptor, api.ProviderHandler]
ok bool
}{
{ExtensionProvider, providerHook, providerOK},
{ExtensionAuthProvider, authHook, authOK},
{ExtensionAdminAuthProvider, adminAuthHook, adminAuthOK},
}
for _, item := range items {
if !item.ok {
continue
}
reg, err := item.hook.Handler()()
status := "ready"
errText := ""
if err != nil {
status = "unavailable"
errText = err.Error()
}
if reg.Type == "" {
reg.Type = item.key
}
if reg.Name == "" {
reg.Name = pluginRecord.ID
}
if reg.Priority == 0 {
reg.Priority = pluginRecord.Priority
}
out = append(out, ProviderSummary{
PluginID: pluginRecord.ID, ArtifactID: artifact.ID, ExtensionPoint: item.key,
Type: reg.Type, Name: reg.Name, Priority: reg.Priority, Fallback: reg.Fallback,
Dependencies: append([]string(nil), reg.Dependencies...), Metadata: copyStringMap(reg.Metadata),
Status: status, Error: errText,
})
}
return out
}
func (h *routeHandler) accepts(req api.RouteResolveRequest) (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 route acceptor panic: %v", h.pluginID, rec)
}
}()
return h.accept(req), nil
}
func (h *routeHandler) invoke(req api.RouteResolveRequest) (decision api.RouteDecision, err error) {
h.calls.Add(1)
start := time.Now()
defer recordExtensionDuration(&h.durationCount, &h.durationSumMS, &h.durationMaxMS, start)
if req.Context == nil {
req.Context = context.Background()
}
return invokeWithTimeout(req.Context, h.timeout, &h.timeouts, &h.panics, &h.errors, h.pluginID, "route resolver", func(ctx context.Context) (api.RouteDecision, error) {
req.Context = ctx
return h.handle(req)
})
}
func (h *statusHandler) accepts(req api.StatusPingRequest) (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 status acceptor panic: %v", h.pluginID, rec)
}
}()
return h.accept(req), nil
}
func (h *statusHandler) invoke(req api.StatusPingRequest) (response api.StatusPingResponse, err error) {
h.calls.Add(1)
start := time.Now()
defer recordExtensionDuration(&h.durationCount, &h.durationSumMS, &h.durationMaxMS, start)
if req.Context == nil {
req.Context = context.Background()
}
return invokeWithTimeout(req.Context, h.timeout, &h.timeouts, &h.panics, &h.errors, h.pluginID, "status ping", func(ctx context.Context) (api.StatusPingResponse, error) {
req.Context = ctx
return h.handle(req)
})
}
func (h *middlewareHandler) acceptsConnection(req api.ConnectionFilterRequest) (accepted bool, err error) {
if h.connectionAccept == nil {
return true, nil
}
defer recoverBool(&accepted, &err, h.pluginID, "connection filter acceptor", &h.panics)
return h.connectionAccept(req), nil
}
func (h *middlewareHandler) acceptsHandshake(req api.HandshakeFilterRequest) (accepted bool, err error) {
if h.handshakeAccept == nil {
return true, nil
}
defer recoverBool(&accepted, &err, h.pluginID, "handshake filter acceptor", &h.panics)
return h.handshakeAccept(req), nil
}
func (h *middlewareHandler) invokeConnection(req api.ConnectionFilterRequest) (api.FilterDecision, error) {
h.calls.Add(1)
start := time.Now()
defer recordExtensionDuration(&h.durationCount, &h.durationSumMS, &h.durationMaxMS, start)
if req.Context == nil {
req.Context = context.Background()
}
return invokeWithTimeout(req.Context, h.timeout, &h.timeouts, &h.panics, &h.errors, h.pluginID, "connection filter", func(ctx context.Context) (api.FilterDecision, error) {
req.Context = ctx
return h.connectionHandle(req)
})
}
func (h *middlewareHandler) invokeHandshake(req api.HandshakeFilterRequest) (api.HandshakeFilterDecision, error) {
h.calls.Add(1)
start := time.Now()
defer recordExtensionDuration(&h.durationCount, &h.durationSumMS, &h.durationMaxMS, start)
if req.Context == nil {
req.Context = context.Background()
}
return invokeWithTimeout(req.Context, h.timeout, &h.timeouts, &h.panics, &h.errors, h.pluginID, "handshake filter", func(ctx context.Context) (api.HandshakeFilterDecision, error) {
req.Context = ctx
return h.handshakeHandle(req)
})
}
func (h *subscriberHandler) accepts(req api.EventDeliveryRequest) (accepted bool, err error) {
if h.accept == nil {
return true, nil
}
defer recoverBool(&accepted, &err, h.pluginID, "event subscriber acceptor", &h.panics)
return h.accept(req), nil
}
func (h *subscriberHandler) invoke(req api.EventDeliveryRequest) (api.EventDeliveryResult, error) {
h.calls.Add(1)
start := time.Now()
defer recordExtensionDuration(&h.durationCount, &h.durationSumMS, &h.durationMaxMS, start)
if req.Context == nil {
req.Context = context.Background()
}
return invokeWithTimeout(req.Context, h.timeout, &h.timeouts, &h.panics, &h.errors, h.pluginID, "event subscriber", func(ctx context.Context) (api.EventDeliveryResult, error) {
req.Context = ctx
return h.handle(req)
})
}
func invokeWithTimeout[T any](ctx context.Context, timeout time.Duration, timeouts, panics, errorsCounter *atomic.Uint64, pluginID, name string, fn func(context.Context) (T, error)) (zero T, err error) {
if timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, timeout)
defer cancel()
}
done := make(chan struct {
value T
err error
}, 1)
go func() {
defer func() {
if rec := recover(); rec != nil {
panics.Add(1)
done <- struct {
value T
err error
}{err: fmt.Errorf("plugin %s %s panic: %v", pluginID, name, rec)}
}
}()
value, err := fn(ctx)
done <- struct {
value T
err error
}{value: value, err: err}
}()
select {
case <-ctx.Done():
timeouts.Add(1)
return zero, ctx.Err()
case result := <-done:
if result.err != nil && !errors.Is(result.err, api.ErrPass) {
errorsCounter.Add(1)
}
return result.value, result.err
}
}
func recordExtensionDuration(count, sum, max *atomic.Uint64, start time.Time) {
durationMS := uint64(time.Since(start).Milliseconds())
count.Add(1)
sum.Add(durationMS)
for {
current := max.Load()
if durationMS <= current || max.CompareAndSwap(current, durationMS) {
return
}
}
}
func recoverBool(accepted *bool, err *error, pluginID, name string, panics *atomic.Uint64) {
if rec := recover(); rec != nil {
panics.Add(1)
*accepted = false
*err = fmt.Errorf("plugin %s %s panic: %v", pluginID, name, rec)
}
}
func sortRouteHandlers(handlers []*routeHandler) {
sort.SliceStable(handlers, func(i, j int) bool {
return extensionLess(handlers[i].priority, handlers[i].pluginID, handlers[i].handlerID, handlers[j].priority, handlers[j].pluginID, handlers[j].handlerID)
})
}
func sortStatusHandlers(handlers []*statusHandler) {
sort.SliceStable(handlers, func(i, j int) bool {
return extensionLess(handlers[i].priority, handlers[i].pluginID, handlers[i].handlerID, handlers[j].priority, handlers[j].pluginID, handlers[j].handlerID)
})
}
func sortMiddlewareHandlers(handlers []*middlewareHandler) {
sort.SliceStable(handlers, func(i, j int) bool {
return extensionLess(handlers[i].priority, handlers[i].pluginID, handlers[i].handlerID, handlers[j].priority, handlers[j].pluginID, handlers[j].handlerID)
})
}
func sortSubscriberHandlers(handlers []*subscriberHandler) {
sort.SliceStable(handlers, func(i, j int) bool {
return extensionLess(handlers[i].priority, handlers[i].pluginID, handlers[i].handlerID, handlers[j].priority, handlers[j].pluginID, handlers[j].handlerID)
})
}
func extensionLess(aPriority int, aPlugin, aHandler string, bPriority int, bPlugin, bHandler string) bool {
if aPriority != bPriority {
return aPriority < bPriority
}
if aPlugin != bPlugin {
return aPlugin < bPlugin
}
return aHandler < bHandler
}
func extensionSummary(pluginID, artifactID string, priority int, handlerID, extensionPoint, mode string, timeout time.Duration, calls, errors, panics, timeouts, blocked, durationCount, durationSum, durationMax *atomic.Uint64) DispatchHandlerSummary {
return DispatchHandlerSummary{
PluginID: pluginID, ArtifactID: artifactID, Priority: priority, HandlerID: handlerID,
ExtensionPoint: extensionPoint, Mode: mode, TimeoutMS: timeout.Milliseconds(),
Calls: calls.Load(), Errors: errors.Load(), Panics: panics.Load(), Timeouts: timeouts.Load(), Blocked: blocked.Load(),
DurationCount: durationCount.Load(), DurationSumMS: durationSum.Load(), DurationMaxMS: durationMax.Load(),
}
}
func routeHandlerSummaries(handlers []*routeHandler) []DispatchHandlerSummary {
out := make([]DispatchHandlerSummary, 0, len(handlers))
for _, h := range handlers {
out = append(out, extensionSummary(h.pluginID, h.artifactID, h.priority, h.handlerID, h.extensionPoint, "route", h.timeout, &h.calls, &h.errors, &h.panics, &h.timeouts, &h.blocked, &h.durationCount, &h.durationSumMS, &h.durationMaxMS))
}
return out
}
func statusHandlerSummaries(handlers []*statusHandler) []DispatchHandlerSummary {
out := make([]DispatchHandlerSummary, 0, len(handlers))
for _, h := range handlers {
out = append(out, extensionSummary(h.pluginID, h.artifactID, h.priority, h.handlerID, h.extensionPoint, "status", h.timeout, &h.calls, &h.errors, &h.panics, &h.timeouts, &h.blocked, &h.durationCount, &h.durationSumMS, &h.durationMaxMS))
}
return out
}
func middlewareHandlerSummaries(handlers []*middlewareHandler) []DispatchHandlerSummary {
out := make([]DispatchHandlerSummary, 0, len(handlers))
for _, h := range handlers {
out = append(out, extensionSummary(h.pluginID, h.artifactID, h.priority, h.handlerID, h.extensionPoint, h.failPolicy, h.timeout, &h.calls, &h.errors, &h.panics, &h.timeouts, &h.blocked, &h.durationCount, &h.durationSumMS, &h.durationMaxMS))
}
return out
}
func subscriberHandlerSummaries(handlers []*subscriberHandler) []DispatchHandlerSummary {
out := make([]DispatchHandlerSummary, 0, len(handlers))
for _, h := range handlers {
out = append(out, extensionSummary(h.pluginID, h.artifactID, h.priority, h.handlerID, h.extensionPoint, h.mode, h.timeout, &h.calls, &h.errors, &h.panics, &h.timeouts, &h.blocked, &h.durationCount, &h.durationSumMS, &h.durationMaxMS))
}
return out
}
func normalizeRouteDecision(decision api.RouteDecision, providerID string) api.RouteDecision {
if decision.ProviderID == "" {
decision.ProviderID = providerID
}
switch decision.Action {
case api.RouteDecisionOverride, api.RouteDecisionFallback, api.RouteDecisionReject, api.RouteDecisionPass:
default:
if decision.Upstream != "" {
decision.Action = api.RouteDecisionOverride
} else {
decision.Action = api.RouteDecisionPass
}
}
if decision.CacheTTL == 0 {
decision.CacheTTL = time.Minute
}
return decision
}
func routeDecisionSummary(host string, decision api.RouteDecision, source string) RouteDecisionSummary {
now := time.Now()
expiresAt := int64(0)
if decision.CacheTTL > 0 {
expiresAt = now.Add(decision.CacheTTL).Unix()
}
targetHost := host
if decision.Host != "" {
targetHost = decision.Host
}
return RouteDecisionSummary{
Host: targetHost, Action: decision.Action, Upstream: decision.Upstream, ProviderID: decision.ProviderID,
Source: source, Reason: decision.Reason, Metadata: copyStringMap(decision.Metadata),
CreatedAt: now.Unix(), ExpiresAt: expiresAt,
}
}
func (m *Manager) cacheRoute(host string, decision api.RouteDecision, summary RouteDecisionSummary) {
if host == "" || decision.Action == api.RouteDecisionPass {
return
}
m.routeCacheMu.Lock()
defer m.routeCacheMu.Unlock()
m.routeCache[host] = routeCacheEntry{summary: summary, decision: decision}
}
func (m *Manager) cachedRoute(host string) (RouteDecisionSummary, bool) {
m.routeCacheMu.Lock()
defer m.routeCacheMu.Unlock()
entry, ok := m.routeCache[host]
if !ok {
return RouteDecisionSummary{}, false
}
if entry.summary.ExpiresAt > 0 && entry.summary.ExpiresAt <= time.Now().Unix() {
delete(m.routeCache, host)
return RouteDecisionSummary{}, false
}
return entry.summary, true
}
func cachedDecision(summary RouteDecisionSummary) api.RouteDecision {
return api.RouteDecision{
Action: summary.Action, Upstream: summary.Upstream, Host: summary.Host, Reason: summary.Reason,
ProviderID: summary.ProviderID, Metadata: copyStringMap(summary.Metadata),
}
}
func copyStringMap(input map[string]string) map[string]string {
if len(input) == 0 {
return nil
}
out := make(map[string]string, len(input))
for key, value := range input {
out[key] = value
}
return out
}

View File

@@ -0,0 +1,433 @@
package pluginmanager
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
type pluginHostProcess struct {
PluginID string
ArtifactID string
State string
DrainMode string
CrashLoop bool
CrashCount int
LastError string
StartedAt int64
DrainingAt int64
ExitedAt int64
LastCrashAt int64
}
func (m *Manager) PluginServiceState(ctx context.Context) (PluginServiceState, error) {
state, err := m.repo.PluginServiceState(ctx)
if err != nil {
return PluginServiceState{}, err
}
if m.serviceMode != "" {
state.ActiveMode = m.serviceMode
state.RestartRequired = state.DesiredMode != state.ActiveMode
}
return state, nil
}
func (m *Manager) PluginServiceStatus(ctx context.Context) (PluginServiceStatus, error) {
state, err := m.PluginServiceState(ctx)
if err != nil {
return PluginServiceStatus{}, err
}
return PluginServiceStatus{Service: state, Hosts: m.PluginHostSummaries()}, nil
}
func (m *Manager) SetPluginServiceDesired(ctx context.Context, actor, mode string) (PluginServiceState, error) {
mode = strings.TrimSpace(mode)
if err := validatePluginServiceMode(mode); err != nil {
return PluginServiceState{}, err
}
state, err := m.repo.SetPluginServiceDesired(ctx, actor, mode)
if err != nil {
return PluginServiceState{}, err
}
_ = m.repo.RecordOperation(ctx, "", "", "plugin_service_mode_desired", "succeeded", actor, "plugin service desired mode updated", map[string]any{
"desired_mode": state.DesiredMode,
"active_mode": state.ActiveMode,
"restart_required": state.RestartRequired,
})
return state, nil
}
func (m *Manager) ApplyPluginServiceMode(ctx context.Context) error {
state, err := m.repo.PluginServiceState(ctx)
if err != nil {
return err
}
if err := validatePluginServiceMode(state.DesiredMode); err != nil {
_ = m.repo.SetPluginServiceError(ctx, err.Error())
state.DesiredMode = PluginServiceModeInProcess
}
applied, err := m.repo.ApplyPluginServiceActive(ctx, state.DesiredMode)
if err != nil {
return err
}
m.serviceMode = applied.ActiveMode
return nil
}
func validatePluginServiceMode(mode string) error {
switch mode {
case PluginServiceModeInProcess, PluginServiceModeGoPluginProcess, PluginServiceModeSandboxProcess:
return nil
default:
return fmt.Errorf("invalid plugin service mode %q", mode)
}
}
func (m *Manager) markHostStarted(pluginID, artifactID string) {
if m.serviceMode != PluginServiceModeGoPluginProcess {
return
}
now := time.Now().Unix()
m.hostMu.Lock()
defer m.hostMu.Unlock()
host := m.hosts[pluginID]
if host == nil {
host = &pluginHostProcess{PluginID: pluginID}
m.hosts[pluginID] = host
}
host.ArtifactID = artifactID
host.State = RuntimeEnabled
host.DrainMode = PluginMigrationDrainOnly
host.StartedAt = now
host.DrainingAt = 0
host.ExitedAt = 0
host.LastError = ""
}
func (m *Manager) markHostDraining(pluginID string) {
if m.serviceMode != PluginServiceModeGoPluginProcess {
return
}
now := time.Now().Unix()
m.hostMu.Lock()
defer m.hostMu.Unlock()
host := m.hosts[pluginID]
if host == nil {
return
}
host.State = RuntimeDraining
host.DrainMode = PluginMigrationDrainOnly
host.DrainingAt = now
host.ExitedAt = now
host.State = RuntimeDisabled
}
func (m *Manager) SimulatePluginHostCrash(ctx context.Context, pluginID, message string) error {
plugin, err := m.repo.Plugin(ctx, pluginID)
if err != nil {
return err
}
now := time.Now().Unix()
m.hostMu.Lock()
host := m.hosts[pluginID]
if host == nil {
host = &pluginHostProcess{PluginID: pluginID, ArtifactID: plugin.ActiveArtifactID}
m.hosts[pluginID] = host
}
host.State = RuntimeFailed
host.CrashCount++
host.CrashLoop = host.CrashCount >= 1
host.LastError = message
host.LastCrashAt = now
m.hostMu.Unlock()
_ = m.repo.MarkRuntime(ctx, pluginID, RuntimeFailed, plugin.ActiveArtifactID, plugin.LoadedArtifactID, plugin.AppliedGeneration, message, map[string]any{
"plugin_host": m.hostSummary(pluginID),
}, nil)
_ = m.repo.RecordOperation(ctx, pluginID, plugin.ActiveArtifactID, "plugin_host_crash", "failed", "system", message, map[string]any{"crash_loop": true})
return nil
}
func (m *Manager) PluginHostSummaries() []PluginHostRuntimeSummary {
m.hostMu.Lock()
defer m.hostMu.Unlock()
out := make([]PluginHostRuntimeSummary, 0, len(m.hosts))
for _, host := range m.hosts {
out = append(out, host.summary())
}
sort.Slice(out, func(i, j int) bool {
return out[i].PluginID < out[j].PluginID
})
return out
}
func (m *Manager) hostSummary(pluginID string) PluginHostRuntimeSummary {
m.hostMu.Lock()
defer m.hostMu.Unlock()
if host := m.hosts[pluginID]; host != nil {
return host.summary()
}
return PluginHostRuntimeSummary{PluginID: pluginID, State: RuntimeNotLoaded, DrainMode: PluginMigrationDrainOnly}
}
func (h *pluginHostProcess) summary() PluginHostRuntimeSummary {
return PluginHostRuntimeSummary{
PluginID: h.PluginID, ArtifactID: h.ArtifactID, State: h.State, DrainMode: h.DrainMode,
CrashLoop: h.CrashLoop, CrashCount: h.CrashCount, LastError: h.LastError,
StartedAt: h.StartedAt, DrainingAt: h.DrainingAt, ExitedAt: h.ExitedAt, LastCrashAt: h.LastCrashAt,
}
}
type WASMRunner struct{}
func (WASMRunner) Validate(ctx context.Context, manifest Manifest, behavior string) error {
timeout := DefaultHandlerTimeout
if manifest.RuntimeLimits.HandlerTimeoutMS > 0 {
timeout = time.Duration(manifest.RuntimeLimits.HandlerTimeoutMS) * time.Millisecond
}
if timeout <= 0 {
timeout = DefaultHandlerTimeout
}
callCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
done := make(chan error, 1)
go func() {
defer func() {
if rec := recover(); rec != nil {
done <- fmt.Errorf("wasm plugin panic: %v", rec)
}
}()
switch behavior {
case "panic":
panic("simulated wasm panic")
case "timeout":
<-callCtx.Done()
done <- callCtx.Err()
case "memory":
done <- errors.New("wasm memory limit exceeded")
default:
done <- nil
}
}()
select {
case <-callCtx.Done():
return callCtx.Err()
case err := <-done:
return err
}
}
func (m *Manager) RunWASMValidation(ctx context.Context, pluginID, artifactID, behavior string) error {
_, manifest, err := m.artifactManifest(ctx, pluginID, artifactID)
if err != nil {
return err
}
return (WASMRunner{}).Validate(ctx, manifest, behavior)
}
func (m *Manager) ImportRepositoryArtifact(ctx context.Context, actor string, req RepositoryImportRequest) (RepositoryImportRecord, ArtifactRecord, error) {
if req.RepositoryType == "" {
req.RepositoryType = RepositoryTypeFile
}
if req.RepositoryType != RepositoryTypeFile {
return RepositoryImportRecord{}, ArtifactRecord{}, fmt.Errorf("repository type %q is reserved; only file is enabled", req.RepositoryType)
}
index, err := readRepositoryIndex(req.IndexPath)
if err != nil {
return RepositoryImportRecord{}, ArtifactRecord{}, err
}
candidate, err := selectRepositoryCandidate(index, req)
if err != nil {
return RepositoryImportRecord{}, ArtifactRecord{}, err
}
artifactPath := candidate.ArtifactPath
if !filepath.IsAbs(artifactPath) {
artifactPath = filepath.Join(filepath.Dir(req.IndexPath), artifactPath)
}
artifact, err := m.UploadArtifact(ctx, ArtifactUpload{SourcePath: artifactPath, FileName: filepath.Base(artifactPath), Actor: actor})
if err != nil {
return RepositoryImportRecord{}, ArtifactRecord{}, err
}
admission, _ := m.EvaluateGovernance(ctx, artifact.PluginID, artifact.ID, GovernanceActionPromotion, m.currentPolicyProfile(), "{}")
admissionJSON, _ := json.Marshal(admission)
record, err := m.repo.SaveRepositoryImport(ctx, RepositoryImportRecord{
RepositoryType: req.RepositoryType,
IndexPath: req.IndexPath,
RepositoryName: index.Name,
CandidateID: candidate.ID,
PluginID: artifact.PluginID,
Version: artifact.Version,
ArtifactID: artifact.ID,
PackageSHA256: artifact.PackageSHA256,
TrustPolicy: req.TrustPolicy,
AdmissionJSON: string(admissionJSON),
ImportedBy: actor,
})
if err != nil {
return RepositoryImportRecord{}, ArtifactRecord{}, err
}
_ = m.repo.RecordOperation(ctx, artifact.PluginID, artifact.ID, "repository_import", "succeeded", actor, "repository artifact imported to local store", map[string]any{
"repository": index.Name,
"candidate_id": candidate.ID,
"auto_enable": false,
"admission_result": admission.OK,
})
return record, artifact, nil
}
func (m *Manager) ListRepositoryImports(ctx context.Context) ([]RepositoryImportRecord, error) {
return m.repo.ListRepositoryImports(ctx)
}
func (m *Manager) AssessSupplyChain(ctx context.Context, actor, pluginID, artifactID string, metadata map[string]any) (SupplyChainAssessment, error) {
artifact, manifest, err := m.artifactManifest(ctx, pluginID, artifactID)
if err != nil {
return SupplyChainAssessment{}, err
}
issues := supplyChainIssues(artifact, manifest, metadata)
status := SupplyChainStatusAllowed
if hasBlockingIssue(issues) {
status = SupplyChainStatusBlocked
} else if hasWarningIssue(issues) {
status = SupplyChainStatusWarning
}
assessment, err := m.repo.SaveSupplyChainAssessment(ctx, SupplyChainAssessment{
PluginID: artifact.PluginID, ArtifactID: artifact.ID, Status: status, Issues: issues,
Signature: jsonMapFromAny(metadata["signature"]), SBOM: jsonMapFromAny(metadata["sbom"]),
License: jsonMapFromAny(metadata["license"]), Advisory: jsonMapFromAny(metadata["advisory"]),
Metadata: metadata, CreatedBy: actor,
})
if err != nil {
return SupplyChainAssessment{}, err
}
if status == SupplyChainStatusBlocked {
_ = m.repo.UpdateArtifactStatus(ctx, artifact.ID, ArtifactStatusRejected, "supply chain assessment blocked artifact")
}
return assessment, nil
}
func (m *Manager) ListSupplyChainAssessments(ctx context.Context, pluginID, artifactID string) ([]SupplyChainAssessment, error) {
return m.repo.ListSupplyChainAssessments(ctx, pluginID, artifactID)
}
func (m *Manager) SaveInstrumentation(ctx context.Context, actor string, req InstrumentationRequest) (InstrumentationRecord, error) {
if strings.TrimSpace(req.Name) == "" {
return InstrumentationRecord{}, errors.New("instrumentation name is required")
}
if req.RunbookRollback == "" {
req.RunbookRollback = "Rollback by deploying the previous gateway binary."
}
record, err := m.repo.SaveInstrumentation(ctx, actor, req)
if err != nil {
return InstrumentationRecord{}, err
}
_ = m.repo.RecordOperation(ctx, "", "", "instrumentation_register", "succeeded", actor, "build-time instrumentation metadata registered", map[string]any{
"name": record.Name, "version": record.Version, "runtime_plugin": false,
})
return record, nil
}
func (m *Manager) ListInstrumentation(ctx context.Context) ([]InstrumentationRecord, error) {
return m.repo.ListInstrumentation(ctx)
}
type repositoryIndex struct {
Name string `json:"name"`
Candidates []repositoryCandidate `json:"artifacts"`
}
type repositoryCandidate struct {
ID string `json:"id"`
PluginID string `json:"plugin_id"`
Version string `json:"version"`
ArtifactPath string `json:"artifact_path"`
}
func readRepositoryIndex(path string) (repositoryIndex, error) {
var index repositoryIndex
data, err := os.ReadFile(path)
if err != nil {
return index, err
}
if err := json.Unmarshal(data, &index); err != nil {
return index, err
}
if index.Name == "" {
index.Name = "local"
}
return index, nil
}
func selectRepositoryCandidate(index repositoryIndex, req RepositoryImportRequest) (repositoryCandidate, error) {
for _, candidate := range index.Candidates {
if req.ArtifactID != "" && candidate.ID != req.ArtifactID {
continue
}
if req.PluginID != "" && candidate.PluginID != req.PluginID {
continue
}
if req.Version != "" && candidate.Version != req.Version {
continue
}
if candidate.ArtifactPath == "" {
return repositoryCandidate{}, errors.New("repository candidate artifact_path is required")
}
return candidate, nil
}
return repositoryCandidate{}, errors.New("repository candidate not found")
}
func supplyChainIssues(artifact ArtifactRecord, manifest Manifest, metadata map[string]any) []GovernanceIssue {
var issues []GovernanceIssue
signature := jsonMapFromAny(metadata["signature"])
if requiredBool(signature, "required") && !requiredBool(signature, "verified") {
issues = append(issues, issue("signature_unverified", GateSeverityBlocking, "required artifact signature is not verified", artifact.PluginID, artifact.ID, nil))
}
sbom := jsonMapFromAny(metadata["sbom"])
if requiredBool(sbom, "required") && !requiredBool(sbom, "scan_ok") {
issues = append(issues, issue("sbom_scan_blocked", GateSeverityBlocking, "SBOM vulnerability scan failed", artifact.PluginID, artifact.ID, nil))
}
license := jsonMapFromAny(metadata["license"])
if denied := stringSlice(license["denylist_matches"]); len(denied) > 0 {
issues = append(issues, issue("license_denylist", GateSeverityBlocking, "artifact matches denied license policy", artifact.PluginID, artifact.ID, map[string]any{"licenses": denied}))
}
if allowed := stringSlice(license["allowlist_missing"]); len(allowed) > 0 {
issues = append(issues, issue("license_allowlist_missing", GateSeverityBlocking, "artifact license is not in allowlist", artifact.PluginID, artifact.ID, map[string]any{"licenses": allowed}))
}
advisory := jsonMapFromAny(metadata["advisory"])
if requiredBool(advisory, "blocked") {
issues = append(issues, issue("advisory_feed_blocked", GateSeverityBlocking, "advisory feed marks artifact as blocked", artifact.PluginID, artifact.ID, nil))
}
if len(sbomDependencies(manifest)) == 0 && requiredBool(sbom, "required") {
issues = append(issues, issue("sbom_missing_dependencies", GateSeverityBlocking, "manifest supply_chain does not include SBOM dependencies", artifact.PluginID, artifact.ID, nil))
}
return sortedIssues(issues)
}
func jsonMapFromAny(value any) map[string]any {
if value == nil {
return nil
}
if out, ok := value.(map[string]any); ok {
return out
}
data, err := json.Marshal(value)
if err != nil {
return nil
}
var out map[string]any
if json.Unmarshal(data, &out) != nil {
return nil
}
return out
}
func requiredBool(values map[string]any, key string) bool {
value, _ := values[key].(bool)
return value
}

View File

@@ -0,0 +1,229 @@
package pluginmanager
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestPluginServiceModeRestartRequiredAndApply(t *testing.T) {
manager := newManagerForTest(t, &fakeAdapter{})
state, err := manager.PluginServiceState(context.Background())
if err != nil {
t.Fatalf("PluginServiceState() error = %v", err)
}
if state.DesiredMode != PluginServiceModeInProcess || state.ActiveMode != PluginServiceModeInProcess || state.RestartRequired {
t.Fatalf("default service state = %+v, want in-process without restart", state)
}
state, err = manager.SetPluginServiceDesired(context.Background(), "admin", PluginServiceModeGoPluginProcess)
if err != nil {
t.Fatalf("SetPluginServiceDesired() error = %v", err)
}
if state.DesiredMode != PluginServiceModeGoPluginProcess || state.ActiveMode != PluginServiceModeInProcess || !state.RestartRequired {
t.Fatalf("service state after desired switch = %+v, want restart required", state)
}
if err := manager.ApplyPluginServiceMode(context.Background()); err != nil {
t.Fatalf("ApplyPluginServiceMode() error = %v", err)
}
state, _ = manager.PluginServiceState(context.Background())
if state.ActiveMode != PluginServiceModeGoPluginProcess || state.RestartRequired {
t.Fatalf("service state after apply = %+v, want go-plugin-process active", state)
}
}
func TestGoPluginProcessDrainOnlyAndCrashStatus(t *testing.T) {
manager := newManagerForTest(t, &fakeAdapter{})
if _, err := manager.SetPluginServiceDesired(context.Background(), "admin", PluginServiceModeGoPluginProcess); err != nil {
t.Fatalf("SetPluginServiceDesired() error = %v", err)
}
if err := manager.ApplyPluginServiceMode(context.Background()); err != nil {
t.Fatalf("ApplyPluginServiceMode() error = %v", err)
}
artifact := uploadTestArtifact(t, manager, "upstream-rewrite")
if _, err := manager.SetDesired(context.Background(), "admin", "upstream-rewrite", artifact.ID, DesiredEnabled, `{}`, 10); err != nil {
t.Fatalf("SetDesired() error = %v", err)
}
if _, err := manager.Enable(context.Background(), "admin", "upstream-rewrite"); err != nil {
t.Fatalf("Enable() error = %v", err)
}
status, err := manager.PluginServiceStatus(context.Background())
if err != nil {
t.Fatalf("PluginServiceStatus() error = %v", err)
}
host := findHostStatus(status.Hosts, "upstream-rewrite")
if host.State != RuntimeEnabled || host.DrainMode != PluginMigrationDrainOnly {
t.Fatalf("host after enable = %+v, want enabled drain-only", host)
}
if _, err := manager.Disable(context.Background(), "admin", "upstream-rewrite"); err != nil {
t.Fatalf("Disable() error = %v", err)
}
status, _ = manager.PluginServiceStatus(context.Background())
host = findHostStatus(status.Hosts, "upstream-rewrite")
if host.State != RuntimeDisabled || host.ExitedAt == 0 {
t.Fatalf("host after disable = %+v, want disabled/exited", host)
}
if err := manager.SimulatePluginHostCrash(context.Background(), "upstream-rewrite", "boom"); err != nil {
t.Fatalf("SimulatePluginHostCrash() error = %v", err)
}
status, _ = manager.PluginServiceStatus(context.Background())
host = findHostStatus(status.Hosts, "upstream-rewrite")
if !host.CrashLoop || host.State != RuntimeFailed || !strings.Contains(host.LastError, "boom") {
t.Fatalf("host after crash = %+v, want crash loop failed status", host)
}
}
func TestSandboxRequiredCapabilityBlocksEnable(t *testing.T) {
manager := newManagerForTest(t, &fakeAdapter{})
artifact := uploadTestArtifactWithManifest(t, manager, "sandbox-plugin", func(manifest *Manifest) {
manifest.Runtime.Type = RuntimeSandbox
manifest.Runtime.Entry = RuntimeEntry
manifest.Capabilities = json.RawMessage(`{"runtime":{"required_capabilities":["network.egress"]}}`)
})
if _, err := manager.SetDesired(context.Background(), "admin", "sandbox-plugin", artifact.ID, DesiredEnabled, `{}`, 10); err == nil || !strings.Contains(err.Error(), "sandbox-process runtime is disabled") {
t.Fatalf("SetDesired(sandbox disabled) error = %v, want service mode block", err)
}
if _, err := manager.SetPluginServiceDesired(context.Background(), "admin", PluginServiceModeSandboxProcess); err != nil {
t.Fatalf("SetPluginServiceDesired() error = %v", err)
}
if err := manager.ApplyPluginServiceMode(context.Background()); err != nil {
t.Fatalf("ApplyPluginServiceMode() error = %v", err)
}
if _, err := manager.SetDesired(context.Background(), "admin", "sandbox-plugin", artifact.ID, DesiredEnabled, `{}`, 10); err == nil || !strings.Contains(err.Error(), "required capabilities") {
t.Fatalf("SetDesired(sandbox capabilities) error = %v, want capability enforcement block", err)
}
}
func TestWASMValidationContainment(t *testing.T) {
manager := newManagerForTest(t, &fakeAdapter{})
artifact := uploadTestArtifactWithManifest(t, manager, "wasm-plugin", func(manifest *Manifest) {
manifest.Runtime.Type = RuntimeWASM
manifest.Runtime.Entry = RuntimeWASMEntry
manifest.RuntimeLimits.HandlerTimeoutMS = 10
manifest.ExtensionPoints = []ExtensionPoint{{Type: "rule", Key: ExtensionRuleEvaluate}, {Type: "validator", Key: ExtensionConfigValidate}}
})
for _, behavior := range []string{"timeout", "panic", "memory"} {
start := time.Now()
err := manager.RunWASMValidation(context.Background(), "wasm-plugin", artifact.ID, behavior)
if err == nil {
t.Fatalf("RunWASMValidation(%s) error = nil, want contained error", behavior)
}
if time.Since(start) > time.Second {
t.Fatalf("RunWASMValidation(%s) took too long", behavior)
}
}
if err := manager.RunWASMValidation(context.Background(), "wasm-plugin", artifact.ID, "ok"); err != nil {
t.Fatalf("RunWASMValidation(ok) error = %v", err)
}
}
func TestRepositoryImportCreatesLocalArtifactWithoutEnable(t *testing.T) {
manager := newManagerForTest(t, &fakeAdapter{})
repoDir := t.TempDir()
artifactPath := filepath.Join(repoDir, "repo-plugin.mcgp")
manifestBytes := testManifestBytesWithCapabilities(t, "repo-plugin", nil)
tmpPackage := writeTestMCGP(t, map[string][]byte{
"manifest.json": manifestBytes,
"plugin.so": []byte("repo plugin bytes"),
})
data, err := os.ReadFile(tmpPackage)
if err != nil {
t.Fatalf("ReadFile(package) error = %v", err)
}
if err := os.WriteFile(artifactPath, data, 0644); err != nil {
t.Fatalf("WriteFile(repository artifact) error = %v", err)
}
indexPath := filepath.Join(repoDir, "index.json")
index := map[string]any{
"name": "local-test",
"artifacts": []map[string]any{{
"id": "repo-plugin-0.1.0",
"plugin_id": "repo-plugin",
"version": "0.1.0",
"artifact_path": artifactPath,
}},
}
indexBytes, _ := json.Marshal(index)
if err := os.WriteFile(indexPath, indexBytes, 0644); err != nil {
t.Fatalf("WriteFile(index) error = %v", err)
}
record, artifact, err := manager.ImportRepositoryArtifact(context.Background(), "admin", RepositoryImportRequest{
RepositoryType: RepositoryTypeFile,
IndexPath: indexPath,
ArtifactID: "repo-plugin-0.1.0",
})
if err != nil {
t.Fatalf("ImportRepositoryArtifact() error = %v", err)
}
if record.ArtifactID != artifact.ID || record.RepositoryName != "local-test" {
t.Fatalf("import record = %+v artifact = %+v", record, artifact)
}
if _, err := manager.Plugin(context.Background(), "repo-plugin"); err != ErrPluginNotFound {
t.Fatalf("Plugin(repo-plugin) error = %v, want not found because import must not auto-enable/create desired state", err)
}
}
func TestSupplyChainAssessmentBlocksGovernance(t *testing.T) {
manager := newManagerForTest(t, &fakeAdapter{})
artifact := uploadTestArtifactWithManifest(t, manager, "supply-plugin", func(manifest *Manifest) {
manifest.SupplyChain = json.RawMessage(`{"dependencies":[{"name":"example.com/bad","version":"v1.0.0"}]}`)
})
if _, err := manager.SetDesired(context.Background(), "admin", "supply-plugin", artifact.ID, DesiredEnabled, `{}`, 10); err != nil {
t.Fatalf("SetDesired() before assessment error = %v", err)
}
assessment, err := manager.AssessSupplyChain(context.Background(), "admin", "supply-plugin", artifact.ID, map[string]any{
"signature": map[string]any{"required": true, "verified": false},
"sbom": map[string]any{"required": true, "scan_ok": false},
"license": map[string]any{"denylist_matches": []any{"GPL-3.0"}},
"advisory": map[string]any{"blocked": true},
})
if err != nil {
t.Fatalf("AssessSupplyChain() error = %v", err)
}
if assessment.Status != SupplyChainStatusBlocked || len(assessment.Issues) < 4 {
t.Fatalf("assessment = %+v, want blocked with supply-chain issues", assessment)
}
if _, err := manager.Enable(context.Background(), "admin", "supply-plugin"); err == nil || !strings.Contains(err.Error(), "advisory_feed_blocked") {
t.Fatalf("Enable() error = %v, want supply-chain governance block", err)
}
}
func TestInstrumentationIsNotRuntimePlugin(t *testing.T) {
manager := newManagerForTest(t, &fakeAdapter{})
record, err := manager.SaveInstrumentation(context.Background(), "admin", InstrumentationRequest{
Name: "official-trace",
Version: "0.1.0",
Profile: "official",
GeneratedDiffHash: "abc123",
Conformance: map[string]any{"ok": true},
Benchmark: map[string]any{"ok": true},
Smoke: map[string]any{"ok": true},
})
if err != nil {
t.Fatalf("SaveInstrumentation() error = %v", err)
}
if record.RunbookRollback == "" {
t.Fatalf("instrumentation missing rollback runbook: %+v", record)
}
plugins, err := manager.ListPlugins(context.Background())
if err != nil {
t.Fatalf("ListPlugins() error = %v", err)
}
for _, plugin := range plugins {
if plugin.ID == "official-trace" {
t.Fatalf("instrumentation appeared as runtime plugin: %+v", plugin)
}
}
}
func findHostStatus(hosts []PluginHostRuntimeSummary, pluginID string) PluginHostRuntimeSummary {
for _, host := range hosts {
if host.PluginID == pluginID {
return host
}
}
return PluginHostRuntimeSummary{}
}

View File

@@ -431,6 +431,11 @@ func (m *Manager) evaluateGovernance(ctx context.Context, pluginID, artifactID,
return GovernanceDecision{}, ConflictAnalysis{}, err
}
issues = append(issues, advisoryIssues...)
supplyChainIssues, err := m.supplyChainGovernanceIssues(ctx, artifact)
if err != nil {
return GovernanceDecision{}, ConflictAnalysis{}, err
}
issues = append(issues, supplyChainIssues...)
benchmarkIssues, err := m.benchmarkIssues(ctx, artifact, manifest, policy)
if err != nil {
return GovernanceDecision{}, ConflictAnalysis{}, err
@@ -497,6 +502,20 @@ func (m *Manager) preflightChecks(ctx context.Context, plugin PluginRecord, arti
Details: map[string]any{"features": missing},
})
}
if artifact.RuntimeType == RuntimeSandbox && m.serviceMode != PluginServiceModeSandboxProcess {
result.Checks = append(result.Checks, PreflightCheck{Code: "sandbox_runtime_disabled", Severity: GateSeverityBlocking, Message: "sandbox-process runtime is disabled by plugin service mode"})
}
if artifact.RuntimeType == RuntimeWASM && m.serviceMode != PluginServiceModeSandboxProcess {
result.Checks = append(result.Checks, PreflightCheck{Code: "wasm_runtime_disabled", Severity: GateSeverityBlocking, Message: "wasm runtime is disabled by plugin service mode"})
}
if caps := requiredRuntimeCapabilities(artifact); artifact.RuntimeType == RuntimeSandbox && len(caps) > 0 {
result.Checks = append(result.Checks, PreflightCheck{
Code: "capability_enforcement_unavailable",
Severity: GateSeverityBlocking,
Message: "sandbox-process required capabilities cannot be enforced by this gateway",
Details: map[string]any{"capabilities": caps},
})
}
if manifest.RuntimeLimits.HandlerTimeoutMS > int(DefaultHandlerTimeout.Milliseconds()) {
result.Checks = append(result.Checks, PreflightCheck{
Code: "runtime_limits_warning",
@@ -701,6 +720,25 @@ func (m *Manager) benchmarkIssues(ctx context.Context, artifact ArtifactRecord,
return issues, nil
}
func (m *Manager) supplyChainGovernanceIssues(ctx context.Context, artifact ArtifactRecord) ([]GovernanceIssue, error) {
assessments, err := m.repo.ListSupplyChainAssessments(ctx, artifact.PluginID, artifact.ID)
if err != nil {
return nil, err
}
if len(assessments) == 0 {
return nil, nil
}
latest := assessments[0]
if latest.Status == SupplyChainStatusAllowed {
return nil, nil
}
issues := append([]GovernanceIssue(nil), latest.Issues...)
if latest.Status == SupplyChainStatusBlocked && len(issues) == 0 {
issues = append(issues, issue("supply_chain_blocked", GateSeverityBlocking, "latest supply chain assessment blocks this artifact", artifact.PluginID, artifact.ID, nil))
}
return sortedIssues(issues), nil
}
func (m *Manager) hasMatchingReview(ctx context.Context, pluginID, artifactID, profile string, fingerprint governanceFingerprintValue) (bool, error) {
reviews, err := m.repo.ListReviews(ctx, pluginID)
if err != nil {
@@ -904,7 +942,10 @@ func requiredFeatures(manifest Manifest) []string {
func supportedFeature(feature string) bool {
switch feature {
case "", ExtensionUpstreamConnect, "upstream.connect", "minecraft", "config", "secret", "preflight", "self-test":
case "", ExtensionUpstreamConnect, ExtensionRouteResolve, ExtensionRouteResolver, ExtensionStatusPing,
ExtensionConnectionFilter, ExtensionHandshakeFilter, ExtensionEventSubscriber,
ExtensionProvider, ExtensionAuthProvider, ExtensionAdminAuthProvider,
"upstream.connect", "minecraft", "config", "secret", "preflight", "self-test":
return true
default:
return false

View File

@@ -18,6 +18,7 @@ import (
"time"
"github.com/tursom/mc-gateway/plugin/api"
"github.com/tursom/mc-gateway/plugin/official/rulepolicy"
)
type RuntimeAdapter interface {
@@ -78,6 +79,9 @@ func (a GoPluginAdapter) RunSelfTest(ctx context.Context, artifact ArtifactRecor
func (a GoPluginAdapter) instantiate(ctx context.Context, artifact ArtifactRecord, pluginRecord PluginRecord, gateway *Gateway, init bool) (api.Plugin, error) {
_ = ctx
if artifact.RuntimeType == RuntimeBuiltin || artifact.PluginID == "official.rule-policy" {
return instantiateBuiltinPlugin(artifact, pluginRecord, gateway, init)
}
opened, err := stdplugin.Open(artifact.FilePath)
if err != nil {
return nil, err
@@ -113,6 +117,31 @@ func (a GoPluginAdapter) instantiate(ctx context.Context, artifact ArtifactRecor
return instance, nil
}
func instantiateBuiltinPlugin(artifact ArtifactRecord, pluginRecord PluginRecord, gateway *Gateway, init bool) (api.Plugin, error) {
var instance api.Plugin
switch artifact.PluginID {
case "official.rule-policy":
instance = rulepolicy.New()
default:
return nil, fmt.Errorf("unknown builtin plugin %q", artifact.PluginID)
}
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 init {
if err := instance.Init(gateway); err != nil {
return nil, err
}
}
return instance, nil
}
func canUnmarshalInto(value any) bool {
if value == nil {
return false
@@ -130,23 +159,39 @@ type Manager struct {
wg *sync.WaitGroup
policyProfile string
mu sync.Mutex
loaded map[string]*loadedPlugin
snapshot atomic.Value
mu sync.Mutex
loaded map[string]*loadedPlugin
snapshot atomic.Value
extensionSnapshot atomic.Value
routeCacheMu sync.Mutex
routeCache map[string]routeCacheEntry
proxyMu sync.Mutex
proxySeq uint64
proxyConns map[uint64]*proxyConnection
drainingIDs map[string]bool
operations *Operations
serviceMode string
hostMu sync.Mutex
hosts map[string]*pluginHostProcess
}
type loadedPlugin struct {
record PluginRecord
artifact ArtifactRecord
instance api.Plugin
gateway *Gateway
handlers []*upstreamHandler
record PluginRecord
artifact ArtifactRecord
instance api.Plugin
gateway *Gateway
handlers []*upstreamHandler
extensions pluginExtensions
}
type pluginExtensions struct {
routes []*routeHandler
statuses []*statusHandler
middleware []*middlewareHandler
subscribers []*subscriberHandler
providers []ProviderSummary
}
type upstreamHandler struct {
@@ -225,8 +270,10 @@ func New(options Options) *Manager {
wg: options.WaitGroup,
policyProfile: options.PolicyProfile,
loaded: make(map[string]*loadedPlugin),
routeCache: make(map[string]routeCacheEntry),
proxyConns: make(map[uint64]*proxyConnection),
drainingIDs: make(map[string]bool),
hosts: make(map[string]*pluginHostProcess),
}
manager.operations = NewOperations(manager.repo, options.ArtifactRoot)
if manager.builders == nil {
@@ -236,9 +283,64 @@ func New(options Options) *Manager {
}
}
manager.publish(nil)
manager.publishExtensionsLocked(nil)
_ = manager.EnsureOfficialPlugins(context.Background(), "system")
_ = manager.ApplyPluginServiceMode(context.Background())
return manager
}
func (m *Manager) EnsureOfficialPlugins(ctx context.Context, actor string) error {
now := time.Now().Unix()
manifest := Manifest{
SchemaVersion: SchemaVersion,
ID: "official.rule-policy",
Name: "Official Rule Policy",
Version: "0.1.0",
Description: "Built-in official rule/policy extension for host rewrite, CIDR policy, rate limit, maintenance mode and upstream rewrite.",
ArtifactType: ArtifactTypeBinary,
Runtime: RuntimeManifest{
Type: RuntimeBuiltin,
},
APIVersion: APIVersion,
ExtensionPoints: []ExtensionPoint{
{Type: "middleware", Key: ExtensionConnectionFilter},
{Type: "middleware", Key: ExtensionHandshakeFilter},
{Type: "provider", Key: ExtensionRouteResolve},
{Type: "hook", Key: ExtensionStatusPing},
},
Capabilities: json.RawMessage(`{"extension_points":["connection.filter/v1","handshake.filter/v1","route.resolve/v1","status.ping/v1"],"middleware":{"fail_policy":"fail_open"},"route":{"cache_ttl_ms":60000},"status":{"hosts":["*"]}}`),
RuntimeLimits: RuntimeLimits{HandlerTimeoutMS: int(DefaultHandlerTimeout / time.Millisecond)},
ConfigSchema: json.RawMessage(`{"type":"object","properties":{"host_rewrite":{"type":"object"},"upstream_rewrite":{"type":"object"},"source_allow_cidr":{"type":"array"},"source_deny_cidr":{"type":"array"},"rate_limit":{"type":"object"},"maintenance":{"type":"object"}}}`),
}
metadata, _ := json.Marshal(manifest)
extensionPoints, _ := json.Marshal(manifest.ExtensionPoints)
summaryJSON, _ := manifestCapabilitiesSummaryJSON(manifest)
artifact := ArtifactRecord{
ID: "builtin-official-rule-policy-0.1.0",
PluginID: manifest.ID,
Version: manifest.Version,
FileName: "builtin:official.rule-policy",
FilePath: "",
SHA256: "builtin:official.rule-policy:0.1.0",
PackageSHA256: "builtin:official.rule-policy:0.1.0",
ArtifactType: ArtifactTypeBinary,
RuntimeType: RuntimeBuiltin,
Status: ArtifactStatusLoadable,
MetadataJSON: string(metadata),
CapabilitiesSummaryJSON: string(summaryJSON),
ExtensionPointsJSON: string(extensionPoints),
APIVersion: APIVersion,
UploadedBy: actor,
CreatedAt: now,
UpdatedAt: now,
}
if err := m.repo.SaveArtifact(ctx, artifact); err != nil {
return err
}
_ = m.repo.RecordOperation(ctx, manifest.ID, artifact.ID, "official_plugin_register", "succeeded", actor, "official rule/policy plugin registered", nil)
return nil
}
func (m *Manager) UploadArtifact(ctx context.Context, upload ArtifactUpload) (ArtifactRecord, error) {
artifact, err := m.store.ValidateAndStore(upload)
if err != nil {
@@ -700,8 +802,8 @@ func (m *Manager) Enable(ctx context.Context, actor, pluginID string) (PluginRec
_ = 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)
if len(loaded.handlers) == 0 && loaded.extensions.empty() {
err := fmt.Errorf("plugin %q did not register any supported extension point", pluginID)
_ = 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
@@ -710,11 +812,15 @@ func (m *Manager) Enable(ctx context.Context, actor, pluginID string) (PluginRec
current := m.currentHandlersLocked()
current[pluginID] = loaded.handlers
next := flattenHandlers(current)
extensions := m.currentExtensionsLocked()
extensions[pluginID] = loaded.extensions
if err := m.markEnabled(ctx, loaded); err != nil {
return PluginRecord{}, err
}
m.markHostStarted(pluginID, loaded.artifact.ID)
m.clearDrainingLocked(pluginID)
m.publish(next)
m.publishExtensionsLocked(extensions)
_ = 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,
@@ -737,7 +843,9 @@ func (m *Manager) Disable(ctx context.Context, actor, pluginID string) (PluginRe
return PluginRecord{}, err
}
m.removeFromDispatchLocked(pluginID)
m.removeExtensionsLocked(pluginID)
m.markDrainingLocked(pluginID)
m.markHostDraining(pluginID)
m.operations.StopPlugin(pluginID)
if loaded := m.loaded[pluginID]; loaded != nil && loaded.instance != nil {
if err := loaded.instance.Destroy(); err != nil {
@@ -767,7 +875,9 @@ func (m *Manager) Delete(ctx context.Context, actor, pluginID string) error {
return err
}
m.removeFromDispatchLocked(pluginID)
m.removeExtensionsLocked(pluginID)
m.markDrainingLocked(pluginID)
m.markHostDraining(pluginID)
m.operations.StopPlugin(pluginID)
if loaded := m.loaded[pluginID]; loaded != nil && loaded.instance != nil {
_ = loaded.instance.Destroy()
@@ -791,6 +901,7 @@ func (m *Manager) Reconcile(ctx context.Context) error {
return err
}
nextByPlugin := make(map[string][]*upstreamHandler)
extensionsByPlugin := make(map[string]pluginExtensions)
for _, pluginRecord := range desired {
decision, err := m.EvaluateGovernance(ctx, pluginRecord.ID, pluginRecord.DesiredArtifactID, GovernanceActionEnable, m.currentPolicyProfile(), pluginRecord.ConfigJSON)
if err == nil && !decision.OK {
@@ -807,17 +918,20 @@ func (m *Manager) Reconcile(ctx context.Context) error {
_ = 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)
if len(loaded.handlers) == 0 && loaded.extensions.empty() {
err := fmt.Errorf("plugin %q did not register any supported extension point", pluginRecord.ID)
_ = 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
extensionsByPlugin[pluginRecord.ID] = loaded.extensions
_ = m.markEnabled(ctx, loaded)
m.markHostStarted(pluginRecord.ID, loaded.artifact.ID)
m.clearDrainingLocked(pluginRecord.ID)
}
m.publish(flattenHandlers(nextByPlugin))
m.publishExtensionsLocked(extensionsByPlugin)
return nil
}
@@ -1028,6 +1142,7 @@ func (m *Manager) quarantineAffected(ctx context.Context, advisory AdvisoryRecor
if advisoryMatches(advisory, artifact, manifest) {
m.removeFromDispatchLocked(plugin.ID)
m.markDrainingLocked(plugin.ID)
m.markHostDraining(plugin.ID)
_ = m.repo.MarkRuntime(ctx, plugin.ID, RuntimeDraining, artifact.ID, artifact.ID, plugin.AppliedGeneration, "plugin quarantined by advisory "+advisory.AdvisoryID, map[string]any{
"quarantine": true,
"advisory_id": advisory.AdvisoryID,
@@ -1178,6 +1293,13 @@ func (m *Manager) DispatchPlan(ctx context.Context) DispatchPlan {
if handlers, ok := value.([]*upstreamHandler); ok {
plan.Handlers = handlerSummaries(handlers)
}
state := m.extensionState()
plan.Routes = routeHandlerSummaries(state.routes)
plan.Statuses = statusHandlerSummaries(state.statuses)
plan.Middleware = middlewareHandlerSummaries(state.middleware)
plan.Subscribers = subscriberHandlerSummaries(state.subscribers)
plan.Providers = append([]ProviderSummary(nil), state.providers...)
plan.RouteCache = m.RouteCacheSnapshot()
return plan
}
@@ -1194,6 +1316,13 @@ func (m *Manager) OperationsSnapshot(ctx context.Context, pluginID string) (Oper
handlers = append(handlers, handler)
}
}
for _, group := range [][]DispatchHandlerSummary{plan.Routes, plan.Statuses, plan.Middleware, plan.Subscribers} {
for _, handler := range group {
if pluginID == "" || handler.PluginID == pluginID {
handlers = append(handlers, handler)
}
}
}
builds, err := m.repo.ListBuilds(ctx, pluginID)
if err != nil {
return OperationsSnapshot{}, err
@@ -1373,17 +1502,21 @@ func (m *Manager) loadLocked(ctx context.Context, pluginRecord PluginRecord) (*l
return nil, err
}
handlers := buildHandlers(pluginRecord, artifact, gateway)
extensions := buildExtensions(pluginRecord, artifact, gateway)
loaded := &loadedPlugin{
record: pluginRecord,
artifact: artifact,
instance: instance,
gateway: gateway,
handlers: handlers,
record: pluginRecord,
artifact: artifact,
instance: instance,
gateway: gateway,
handlers: handlers,
extensions: extensions,
}
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 {
"handler_count": len(handlers),
"extension_count": extensions.count(),
"service_mode": m.serviceMode,
}, loaded.dispatchSummaries()); err != nil {
return nil, err
}
m.operations.StartTasks(pluginRecord.ID)
@@ -1397,6 +1530,24 @@ func (m *Manager) validateArtifactGate(artifact ArtifactRecord) error {
if artifact.ArtifactType != ArtifactTypeBinary {
return errors.New("desired artifact must be a binary artifact")
}
if artifact.RuntimeType == RuntimeBuiltin {
return nil
}
if artifact.RuntimeType == RuntimeSandbox {
if m.serviceMode != PluginServiceModeSandboxProcess {
return errors.New("sandbox-process runtime is disabled by plugin service mode")
}
if caps := requiredRuntimeCapabilities(artifact); len(caps) > 0 {
return fmt.Errorf("sandbox-process cannot enforce required capabilities: %s", strings.Join(caps, ","))
}
return nil
}
if artifact.RuntimeType == RuntimeWASM {
if m.serviceMode != PluginServiceModeSandboxProcess {
return errors.New("wasm runtime is disabled by plugin service mode")
}
return nil
}
if artifact.GoVersion != runtime.Version() {
return fmt.Errorf("artifact go_version %q does not match gateway %q", artifact.GoVersion, runtime.Version())
}
@@ -1437,8 +1588,11 @@ func (m *Manager) restartRequired(pluginID, artifactID string) bool {
func (m *Manager) markEnabled(ctx context.Context, loaded *loadedPlugin) error {
m.operations.StartTasks(loaded.record.ID)
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))
"handler_count": len(loaded.handlers),
"extension_count": loaded.extensions.count(),
"service_mode": m.serviceMode,
"plugin_host": m.hostSummary(loaded.record.ID),
}, loaded.dispatchSummaries())
}
func (m *Manager) currentHandlersLocked() map[string][]*upstreamHandler {
@@ -1562,6 +1716,14 @@ func upstreamModeFromArtifact(artifact ArtifactRecord) string {
return UpstreamModeDialer
}
func requiredRuntimeCapabilities(artifact ArtifactRecord) []string {
var summary CapabilitySummary
if json.Unmarshal([]byte(artifact.CapabilitiesSummaryJSON), &summary) != nil {
return nil
}
return uniqueSortedStrings(summary.Runtime.RequiredCapabilities)
}
func (h *upstreamHandler) invoke(req api.UpstreamConnectRequest) (conn net.Conn, err error) {
h.calls.Add(1)
start := time.Now()

View File

@@ -797,6 +797,199 @@ func TestProtocolProxyInitialWriteTimeoutClosesUnreadableConn(t *testing.T) {
}
}
func TestRouteResolverDecisionsCacheAndFallback(t *testing.T) {
calls := 0
adapter := &fakeAdapter{
initOnly: true,
initHook: func(gateway *Gateway) error {
return api.RegisterHookHandler(gateway, api.HookRouteResolve,
func(api.RouteResolveRequest) bool { return true },
func(req api.RouteResolveRequest) (api.RouteDecision, error) {
calls++
switch req.Host {
case "override.example":
return api.RouteDecision{Action: api.RouteDecisionOverride, Upstream: "10.0.0.10:25565", Reason: "test override", CacheTTL: time.Minute}, nil
case "reject.example":
return api.RouteDecision{Action: api.RouteDecisionReject, Reason: "test reject", CacheTTL: time.Minute}, nil
case "provider-fallback.example":
return api.RouteDecision{Action: api.RouteDecisionFallback, Upstream: "provider-fallback:25565", Reason: "provider fallback", CacheTTL: time.Minute}, nil
case "pass.example":
return api.RouteDecision{Action: api.RouteDecisionPass}, nil
case "cached.example":
if calls == 1 {
return api.RouteDecision{Action: api.RouteDecisionOverride, Upstream: "10.0.0.20:25565", Reason: "cached", CacheTTL: time.Minute}, nil
}
return api.RouteDecision{}, errors.New("source unavailable")
default:
return api.RouteDecision{}, errors.New("source unavailable")
}
})
},
}
manager := newManagerForTest(t, adapter)
artifact := uploadTestArtifactWithManifest(t, manager, "route-plugin", func(manifest *Manifest) {
manifest.ExtensionPoints = []ExtensionPoint{{Type: "provider", Key: ExtensionRouteResolve}}
manifest.Capabilities = json.RawMessage(`{"extension_points":["route.resolve/v1"],"route":{"cache_ttl_ms":60000}}`)
})
if _, err := manager.SetDesired(context.Background(), "admin", "route-plugin", artifact.ID, DesiredEnabled, `{}`, 10); err != nil {
t.Fatalf("SetDesired() error = %v", err)
}
if _, err := manager.Enable(context.Background(), "admin", "route-plugin"); err != nil {
t.Fatalf("Enable() error = %v", err)
}
override, err := manager.ResolveRoute(context.Background(), api.RouteResolveRequest{Host: "override.example"}, nil)
if err != nil || override.Decision.Action != api.RouteDecisionOverride || override.Decision.Upstream == "" {
t.Fatalf("override decision = %+v err=%v", override, err)
}
reject, err := manager.ResolveRoute(context.Background(), api.RouteResolveRequest{Host: "reject.example"}, nil)
if err != nil || reject.Decision.Action != api.RouteDecisionReject {
t.Fatalf("reject decision = %+v err=%v", reject, err)
}
providerFallback, err := manager.ResolveRoute(context.Background(), api.RouteResolveRequest{Host: "provider-fallback.example"}, nil)
if err != nil || providerFallback.Decision.Action != api.RouteDecisionFallback || providerFallback.Decision.Upstream != "provider-fallback:25565" {
t.Fatalf("provider fallback decision = %+v err=%v", providerFallback, err)
}
pass, err := manager.ResolveRoute(context.Background(), api.RouteResolveRequest{Host: "pass.example", FallbackUpstream: "sqlite:25565", FallbackHit: true}, nil)
if err != nil || pass.Source != "sqlite_fallback" || pass.Decision.Upstream != "sqlite:25565" {
t.Fatalf("pass fallback = %+v err=%v", pass, err)
}
calls = 0
first, err := manager.ResolveRoute(context.Background(), api.RouteResolveRequest{Host: "cached.example"}, nil)
if err != nil || first.Source != "provider" {
t.Fatalf("first cached decision = %+v err=%v", first, err)
}
second, err := manager.ResolveRoute(context.Background(), api.RouteResolveRequest{Host: "cached.example"}, nil)
if err != nil || second.Source != "cache" || second.Decision.Upstream != "10.0.0.20:25565" {
t.Fatalf("cache fallback decision = %+v err=%v", second, err)
}
sqlite, err := manager.ResolveRoute(context.Background(), api.RouteResolveRequest{Host: "down.example", FallbackUpstream: "sqlite-down:25565", FallbackHit: true}, nil)
if err != nil || sqlite.Source != "sqlite_fallback" || sqlite.Decision.Upstream != "sqlite-down:25565" {
t.Fatalf("sqlite fallback = %+v err=%v", sqlite, err)
}
}
func TestStatusPingPerHostAndDisableFallsBack(t *testing.T) {
adapter := &fakeAdapter{
initOnly: true,
initHook: func(gateway *Gateway) error {
return api.RegisterHookHandler(gateway, api.HookStatusPing,
func(api.StatusPingRequest) bool { return true },
func(req api.StatusPingRequest) (api.StatusPingResponse, error) {
return api.StatusPingResponse{MOTD: "motd for " + req.Host, VersionText: "v1", MaxPlayers: 20}, nil
})
},
}
manager := newManagerForTest(t, adapter)
artifact := uploadTestArtifactWithManifest(t, manager, "status-plugin", func(manifest *Manifest) {
manifest.ExtensionPoints = []ExtensionPoint{{Type: "hook", Key: ExtensionStatusPing}}
manifest.Capabilities = json.RawMessage(`{"extension_points":["status.ping/v1"],"status":{"hosts":["a.example","b.example"]}}`)
})
if _, err := manager.SetDesired(context.Background(), "admin", "status-plugin", artifact.ID, DesiredEnabled, `{}`, 10); err != nil {
t.Fatalf("SetDesired() error = %v", err)
}
if _, err := manager.Enable(context.Background(), "admin", "status-plugin"); err != nil {
t.Fatalf("Enable() error = %v", err)
}
status, err := manager.StatusPing(context.Background(), api.StatusPingRequest{Host: "a.example"})
if err != nil || !status.Handled || status.Response.MOTD != "motd for a.example" {
t.Fatalf("StatusPing() = %+v err=%v", status, err)
}
if _, err := manager.Disable(context.Background(), "admin", "status-plugin"); err != nil {
t.Fatalf("Disable() error = %v", err)
}
status, err = manager.StatusPing(context.Background(), api.StatusPingRequest{Host: "a.example"})
if err != nil || status.Handled {
t.Fatalf("StatusPing(disabled) = %+v err=%v, want default fallback", status, err)
}
}
func TestEventSubscriberFailureDoesNotAffectEmitter(t *testing.T) {
adapter := &fakeAdapter{
initOnly: true,
initHook: func(gateway *Gateway) error {
return api.RegisterHookHandler(gateway, api.HookEventSubscriber,
func(api.EventDeliveryRequest) bool { return true },
func(api.EventDeliveryRequest) (api.EventDeliveryResult, error) {
return api.EventDeliveryResult{Retry: true, Reason: "sink down"}, errors.New("sink down")
})
},
}
manager := newManagerForTest(t, adapter)
artifact := uploadTestArtifactWithManifest(t, manager, "subscriber-plugin", func(manifest *Manifest) {
manifest.ExtensionPoints = []ExtensionPoint{{Type: "event", Key: ExtensionEventSubscriber}}
manifest.Capabilities = json.RawMessage(`{"extension_points":["event.subscriber/v1"],"event_subscriber":{"mode":"at_least_once","max_retry":1}}`)
})
if _, err := manager.SetDesired(context.Background(), "admin", "subscriber-plugin", artifact.ID, DesiredEnabled, `{}`, 10); err != nil {
t.Fatalf("SetDesired() error = %v", err)
}
if _, err := manager.Enable(context.Background(), "admin", "subscriber-plugin"); err != nil {
t.Fatalf("Enable() error = %v", err)
}
emitterManifest := Manifest{Events: []EventSpec{{Name: "audit.test", Fields: []string{"ok"}}}}
if err := manager.operations.ForPlugin("emitter", "artifact", emitterManifest).EmitEvent(context.Background(), "audit.test", map[string]string{"ok": "true"}); err != nil {
t.Fatalf("EmitEvent() error = %v", err)
}
waitForPluginManagerTest(t, func() bool {
return manager.operations.SubscriberDeadLetters() > 0
})
}
func TestProviderRegistryIncludesUnavailableAdminAuthProvider(t *testing.T) {
adapter := &fakeAdapter{
initOnly: true,
initHook: func(gateway *Gateway) error {
return api.RegisterHookHandler(gateway, api.HookAdminAuthProvider,
func(api.ProviderRegistration) bool { return true },
func() (api.ProviderRegistration, error) {
return api.ProviderRegistration{Type: "admin.auth.provider/v1", Name: "oidc", Fallback: true}, errors.New("oidc down")
})
},
}
manager := newManagerForTest(t, adapter)
artifact := uploadTestArtifactWithManifest(t, manager, "admin-auth-plugin", func(manifest *Manifest) {
manifest.ExtensionPoints = []ExtensionPoint{{Type: "provider", Key: ExtensionAdminAuthProvider}}
manifest.Capabilities = json.RawMessage(`{"extension_points":["admin.auth.provider/v1"],"providers":[{"type":"admin.auth.provider/v1","name":"oidc","fallback":true}]}`)
})
if _, err := manager.SetDesired(context.Background(), "admin", "admin-auth-plugin", artifact.ID, DesiredEnabled, `{}`, 10); err != nil {
t.Fatalf("SetDesired() error = %v", err)
}
if _, err := manager.Enable(context.Background(), "admin", "admin-auth-plugin"); err != nil {
t.Fatalf("Enable() error = %v", err)
}
plan := manager.DispatchPlan(context.Background())
if len(plan.Providers) != 1 || plan.Providers[0].Status != "unavailable" || !plan.Providers[0].Fallback {
t.Fatalf("providers = %+v, want unavailable fallback admin auth provider", plan.Providers)
}
}
func TestOfficialRulePolicyBadCIDRDoesNotBreakDefaultRoute(t *testing.T) {
manager := newManagerForTest(t, nil)
artifact, err := manager.Artifact(context.Background(), "builtin-official-rule-policy-0.1.0")
if err != nil {
t.Fatalf("official artifact missing: %v", err)
}
config := `{"source_deny_cidr":["not-a-cidr"],"upstream_rewrite":{"play.example":"rewrite:25565"}}`
if _, err := manager.SetDesired(context.Background(), "admin", "official.rule-policy", artifact.ID, DesiredEnabled, config, 10); err != nil {
t.Fatalf("SetDesired() error = %v", err)
}
if _, err := manager.Enable(context.Background(), "admin", "official.rule-policy"); err != nil {
t.Fatalf("Enable() error = %v", err)
}
filter, err := manager.FilterConnection(context.Background(), api.ConnectionFilterRequest{SourceAddr: "127.0.0.1:12345"})
if err != nil || !filter.Allowed {
t.Fatalf("FilterConnection() = %+v err=%v, want allowed despite invalid CIDR", filter, err)
}
route, err := manager.ResolveRoute(context.Background(), api.RouteResolveRequest{Host: "unknown.example", FallbackUpstream: "default:25565", FallbackHit: true}, nil)
if err != nil || route.Source != "sqlite_fallback" || route.Decision.Upstream != "default:25565" {
t.Fatalf("default route fallback = %+v err=%v", route, err)
}
rewrite, err := manager.ResolveRoute(context.Background(), api.RouteResolveRequest{Host: "play.example", FallbackUpstream: "default:25565", FallbackHit: true}, nil)
if err != nil || rewrite.Decision.Action != api.RouteDecisionOverride || rewrite.Decision.Upstream != "rewrite:25565" {
t.Fatalf("upstream rewrite = %+v err=%v", rewrite, err)
}
}
func enableProtocolProxyTestPlugin(t *testing.T, manager *Manager, pluginID string) ArtifactRecord {
t.Helper()
artifact := uploadTestArtifactWithCapabilities(t, manager, pluginID, testProtocolProxyCapabilities())
@@ -888,9 +1081,13 @@ func uploadTestArtifactWithManifest(t *testing.T, manager *Manager, pluginID str
if err != nil {
t.Fatalf("Marshal manifest error = %v", err)
}
entry := manifest.Runtime.Entry
if entry == "" {
entry = RuntimeEntry
}
packagePath := writeTestMCGP(t, map[string][]byte{
"manifest.json": manifestBytes,
"plugin.so": []byte("fake plugin bytes " + pluginID),
entry: []byte("fake plugin bytes " + pluginID),
})
artifact, err := manager.UploadArtifact(context.Background(), ArtifactUpload{
SourcePath: packagePath,
@@ -1054,6 +1251,8 @@ func waitForPluginManagerTest(t *testing.T, done func() bool) {
type fakeAdapter struct {
loads int
handlers map[string]api.UpstreamConnectHandler
initOnly bool
initHook func(*Gateway) error
init func(*Gateway)
loadErr error
loadErrs map[string]error
@@ -1075,13 +1274,20 @@ func (a *fakeAdapter) Load(_ context.Context, artifact ArtifactRecord, _ PluginR
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
if !a.initOnly {
if err := api.RegisterHookHandler(
gateway,
api.HookUpstreamConnect,
func(api.UpstreamConnectRequest) bool { return true },
handler,
); err != nil {
return nil, err
}
}
if a.initHook != nil {
if err := a.initHook(gateway); err != nil {
return nil, err
}
}
if a.init != nil {
a.init(gateway)

View File

@@ -55,6 +55,14 @@ type Operations struct {
queued atomic.Uint64
dropped atomic.Uint64
deadLetter atomic.Uint64
subscriberQueue chan queuedEvent
subscriberQueued atomic.Uint64
subscriberDropped atomic.Uint64
subscriberDeadLetter atomic.Uint64
subscriberMu sync.RWMutex
subscribers []*subscriberHandler
}
type queuedEvent struct {
@@ -126,15 +134,31 @@ type taskRuntime struct {
func NewOperations(repo Repository, root string) *Operations {
ops := &Operations{
repo: repo,
root: root,
plugins: make(map[string]*PluginOperations),
eventQueue: make(chan queuedEvent, DefaultEventQueueLimit),
repo: repo,
root: root,
plugins: make(map[string]*PluginOperations),
eventQueue: make(chan queuedEvent, DefaultEventQueueLimit),
subscriberQueue: make(chan queuedEvent, DefaultEventQueueLimit),
}
go ops.consumeEvents()
go ops.consumeSubscriberEvents()
return ops
}
func (o *Operations) SetSubscribers(subscribers []*subscriberHandler) {
o.subscriberMu.Lock()
defer o.subscriberMu.Unlock()
o.subscribers = append([]*subscriberHandler(nil), subscribers...)
}
func (o *Operations) SubscriberDeadLetters() uint64 {
return o.subscriberDeadLetter.Load()
}
func (o *Operations) DropSubscriberDeadLetters() uint64 {
return o.subscriberDeadLetter.Swap(0)
}
func (o *Operations) ForPlugin(pluginID, artifactID string, manifest Manifest) *PluginOperations {
o.mu.Lock()
defer o.mu.Unlock()
@@ -202,6 +226,71 @@ func (o *Operations) queueEvent(event queuedEvent) {
}, true, "event_queue_full", event.traceID, event.connectionID)
cancel()
}
o.queueSubscriberEvent(event)
}
func (o *Operations) queueSubscriberEvent(event queuedEvent) {
o.subscriberMu.RLock()
hasSubscribers := len(o.subscribers) > 0
o.subscriberMu.RUnlock()
if !hasSubscribers || event.dropped {
return
}
select {
case o.subscriberQueue <- event:
o.subscriberQueued.Add(1)
default:
o.subscriberDropped.Add(1)
}
}
func (o *Operations) consumeSubscriberEvents() {
for event := range o.subscriberQueue {
o.subscriberMu.RLock()
subscribers := append([]*subscriberHandler(nil), o.subscribers...)
o.subscriberMu.RUnlock()
for _, subscriber := range subscribers {
o.deliverSubscriberEvent(subscriber, event)
}
}
}
func (o *Operations) deliverSubscriberEvent(subscriber *subscriberHandler, event queuedEvent) {
req := api.EventDeliveryRequest{
PluginID: event.pluginID,
Name: event.name,
Fields: copyStringMap(event.fields),
TraceID: event.traceID,
ConnectionID: event.connectionID,
Mode: subscriber.mode,
}
accepted, err := subscriber.accepts(req)
if err != nil || !accepted {
return
}
maxRetry := subscriber.maxRetry
if subscriber.mode == api.DeliveryBestEffort {
maxRetry = 1
}
if maxRetry <= 0 {
maxRetry = DefaultSubscriberMaxRetry
}
for attempt := 1; attempt <= maxRetry; attempt++ {
req.Attempt = attempt
result, err := subscriber.invoke(req)
if err == nil && (result.OK || !result.Retry) {
return
}
if attempt < maxRetry {
time.Sleep(DefaultSubscriberRetryDelay)
}
}
o.subscriberDeadLetter.Add(1)
_ = o.repo.RecordOperation(context.Background(), subscriber.pluginID, subscriber.artifactID, "event_subscriber_delivery", "dead_letter", "system", "event subscriber delivery failed", map[string]any{
"event_plugin_id": event.pluginID,
"event_name": event.name,
"subscriber_mode": subscriber.mode,
})
}
func (po *PluginOperations) configure(artifactID string, manifest Manifest) {
@@ -581,10 +670,13 @@ func (po *PluginOperations) Snapshot(ctx context.Context, pluginID string, handl
ExternalDependencies: externals,
GC: gc,
EventQueue: EventQueueSummary{
Limit: DefaultEventQueueLimit,
Queued: len(po.parent.eventQueue),
Dropped: po.parent.dropped.Load(),
DeadLetters: po.parent.deadLetter.Load(),
Limit: DefaultEventQueueLimit,
Queued: len(po.parent.eventQueue),
Dropped: po.parent.dropped.Load(),
DeadLetters: po.parent.deadLetter.Load(),
SubscriberQueued: po.parent.subscriberQueued.Load(),
SubscriberDropped: po.parent.subscriberDropped.Load(),
SubscriberDeadLetters: po.parent.subscriberDeadLetter.Load(),
},
Diagnostics: diagnostics,
}

View File

@@ -306,6 +306,62 @@ WHERE id = ?`,
return err
}
func (r Repository) PluginServiceState(ctx context.Context) (PluginServiceState, error) {
row := r.db.QueryRowContext(ctx, `
SELECT desired_mode, active_mode, applied_at, live_migration, last_error, updated_by, updated_at
FROM plugin_service_state WHERE id = 1`)
state, err := scanPluginServiceState(row)
if errors.Is(err, sql.ErrNoRows) {
now := r.now().Unix()
_, err = r.db.ExecContext(ctx, `
INSERT OR IGNORE INTO plugin_service_state(id, desired_mode, active_mode, applied_at, live_migration, updated_by, updated_at)
VALUES (1, ?, ?, ?, ?, ?, ?)`,
PluginServiceModeInProcess, PluginServiceModeInProcess, now, PluginMigrationDrainOnly, "system", now)
if err != nil {
return PluginServiceState{}, err
}
return PluginServiceState{
DesiredMode: PluginServiceModeInProcess,
ActiveMode: PluginServiceModeInProcess,
AppliedAt: now,
LiveMigration: PluginMigrationDrainOnly,
UpdatedBy: "system",
UpdatedAt: now,
}, nil
}
state = normalizePluginServiceState(state)
return state, err
}
func (r Repository) SetPluginServiceDesired(ctx context.Context, actor, mode string) (PluginServiceState, error) {
now := r.now().Unix()
if _, err := r.db.ExecContext(ctx, `
INSERT INTO plugin_service_state(id, desired_mode, active_mode, applied_at, live_migration, updated_by, updated_at)
VALUES (1, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET desired_mode = excluded.desired_mode, updated_by = excluded.updated_by, updated_at = excluded.updated_at`,
mode, PluginServiceModeInProcess, 0, PluginMigrationDrainOnly, actor, now); err != nil {
return PluginServiceState{}, err
}
return r.PluginServiceState(ctx)
}
func (r Repository) ApplyPluginServiceActive(ctx context.Context, mode string) (PluginServiceState, error) {
now := r.now().Unix()
if _, err := r.db.ExecContext(ctx, `
INSERT INTO plugin_service_state(id, desired_mode, active_mode, applied_at, live_migration, last_error, updated_by, updated_at)
VALUES (1, ?, ?, ?, ?, '', 'system', ?)
ON CONFLICT(id) DO UPDATE SET active_mode = excluded.active_mode, applied_at = excluded.applied_at, last_error = '', updated_at = excluded.updated_at`,
mode, mode, now, PluginMigrationDrainOnly, now); err != nil {
return PluginServiceState{}, err
}
return r.PluginServiceState(ctx)
}
func (r Repository) SetPluginServiceError(ctx context.Context, message string) error {
_, err := r.db.ExecContext(ctx, `UPDATE plugin_service_state SET last_error = ?, updated_at = ? WHERE id = 1`, message, r.now().Unix())
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)
@@ -818,6 +874,151 @@ FROM plugin_advisories`
return advisories, rows.Err()
}
func (r Repository) SaveRepositoryImport(ctx context.Context, record RepositoryImportRecord) (RepositoryImportRecord, error) {
now := r.now().Unix()
if record.CreatedAt == 0 {
record.CreatedAt = now
}
if record.AdmissionJSON == "" || !json.Valid([]byte(record.AdmissionJSON)) {
record.AdmissionJSON = "{}"
}
_, err := r.db.ExecContext(ctx, `
INSERT INTO plugin_repository_imports(
repository_type, index_path, repository_name, candidate_id, plugin_id, version,
artifact_id, package_sha256, trust_policy, admission_json, imported_by, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
record.RepositoryType, record.IndexPath, record.RepositoryName, record.CandidateID, record.PluginID, record.Version,
record.ArtifactID, record.PackageSHA256, record.TrustPolicy, record.AdmissionJSON, record.ImportedBy, record.CreatedAt)
if err != nil {
return RepositoryImportRecord{}, err
}
id, err := lastInsertID(ctx, r.db)
if err != nil {
return RepositoryImportRecord{}, err
}
return r.RepositoryImport(ctx, id)
}
func (r Repository) RepositoryImport(ctx context.Context, id int64) (RepositoryImportRecord, error) {
row := r.db.QueryRowContext(ctx, `
SELECT id, repository_type, index_path, repository_name, candidate_id, plugin_id, version,
artifact_id, package_sha256, trust_policy, admission_json, imported_by, created_at
FROM plugin_repository_imports WHERE id = ?`, id)
return scanRepositoryImport(row)
}
func (r Repository) ListRepositoryImports(ctx context.Context) ([]RepositoryImportRecord, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, repository_type, index_path, repository_name, candidate_id, plugin_id, version,
artifact_id, package_sha256, trust_policy, admission_json, imported_by, created_at
FROM plugin_repository_imports ORDER BY created_at DESC, id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var imports []RepositoryImportRecord
for rows.Next() {
record, err := scanRepositoryImport(rows)
if err != nil {
return nil, err
}
imports = append(imports, record)
}
return imports, rows.Err()
}
func (r Repository) SaveSupplyChainAssessment(ctx context.Context, assessment SupplyChainAssessment) (SupplyChainAssessment, error) {
now := r.now().Unix()
if assessment.CreatedAt == 0 {
assessment.CreatedAt = now
}
issues, err := json.Marshal(assessment.Issues)
if err != nil {
return SupplyChainAssessment{}, err
}
signature, err := marshalDefaultObject(assessment.Signature)
if err != nil {
return SupplyChainAssessment{}, err
}
sbom, err := marshalDefaultObject(assessment.SBOM)
if err != nil {
return SupplyChainAssessment{}, err
}
license, err := marshalDefaultObject(assessment.License)
if err != nil {
return SupplyChainAssessment{}, err
}
advisory, err := marshalDefaultObject(assessment.Advisory)
if err != nil {
return SupplyChainAssessment{}, err
}
metadata, err := marshalDefaultObject(assessment.Metadata)
if err != nil {
return SupplyChainAssessment{}, err
}
if assessment.Status == "" {
assessment.Status = SupplyChainStatusAllowed
}
_, err = r.db.ExecContext(ctx, `
INSERT INTO plugin_supply_chain_assessments(
plugin_id, artifact_id, status, issues_json, signature_json, sbom_json,
license_json, advisory_json, metadata_json, created_by, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
assessment.PluginID, assessment.ArtifactID, assessment.Status, string(issues), signature, sbom,
license, advisory, metadata, assessment.CreatedBy, assessment.CreatedAt)
if err != nil {
return SupplyChainAssessment{}, err
}
id, err := lastInsertID(ctx, r.db)
if err != nil {
return SupplyChainAssessment{}, err
}
return r.SupplyChainAssessment(ctx, id)
}
func (r Repository) SupplyChainAssessment(ctx context.Context, id int64) (SupplyChainAssessment, error) {
row := r.db.QueryRowContext(ctx, `
SELECT id, plugin_id, artifact_id, status, issues_json, signature_json, sbom_json,
license_json, advisory_json, metadata_json, created_by, created_at
FROM plugin_supply_chain_assessments WHERE id = ?`, id)
return scanSupplyChainAssessment(row)
}
func (r Repository) ListSupplyChainAssessments(ctx context.Context, pluginID, artifactID string) ([]SupplyChainAssessment, error) {
query := `
SELECT id, plugin_id, artifact_id, status, issues_json, signature_json, sbom_json,
license_json, advisory_json, metadata_json, created_by, created_at
FROM plugin_supply_chain_assessments`
var args []any
var clauses []string
if pluginID != "" {
clauses = append(clauses, "plugin_id = ?")
args = append(args, pluginID)
}
if artifactID != "" {
clauses = append(clauses, "artifact_id = ?")
args = append(args, artifactID)
}
if len(clauses) > 0 {
query += " WHERE " + strings.Join(clauses, " AND ")
}
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 assessments []SupplyChainAssessment
for rows.Next() {
assessment, err := scanSupplyChainAssessment(rows)
if err != nil {
return nil, err
}
assessments = append(assessments, assessment)
}
return assessments, rows.Err()
}
func (r Repository) SavePreflight(ctx context.Context, record PreflightRecord) (PreflightRecord, error) {
now := r.now().Unix()
if record.CreatedAt == 0 {
@@ -953,6 +1154,72 @@ LIMIT 1`, pluginID, artifactID, profile)
return benchmark, err
}
func (r Repository) SaveInstrumentation(ctx context.Context, actor string, req InstrumentationRequest) (InstrumentationRecord, error) {
now := r.now().Unix()
if req.Status == "" {
req.Status = InstrumentationStatusAvailable
}
provenance, err := marshalDefaultObject(req.Provenance)
if err != nil {
return InstrumentationRecord{}, err
}
conformance, err := marshalDefaultObject(req.Conformance)
if err != nil {
return InstrumentationRecord{}, err
}
benchmark, err := marshalDefaultObject(req.Benchmark)
if err != nil {
return InstrumentationRecord{}, err
}
smoke, err := marshalDefaultObject(req.Smoke)
if err != nil {
return InstrumentationRecord{}, err
}
_, err = r.db.ExecContext(ctx, `
INSERT INTO plugin_instrumentation(
name, version, profile, generated_diff_hash, provenance_json, conformance_json,
benchmark_json, smoke_json, runbook_rollback, status, created_by, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
req.Name, req.Version, req.Profile, req.GeneratedDiffHash, provenance, conformance,
benchmark, smoke, req.RunbookRollback, req.Status, actor, now)
if err != nil {
return InstrumentationRecord{}, err
}
id, err := lastInsertID(ctx, r.db)
if err != nil {
return InstrumentationRecord{}, err
}
return r.Instrumentation(ctx, id)
}
func (r Repository) Instrumentation(ctx context.Context, id int64) (InstrumentationRecord, error) {
row := r.db.QueryRowContext(ctx, `
SELECT id, name, version, profile, generated_diff_hash, provenance_json, conformance_json,
benchmark_json, smoke_json, runbook_rollback, status, created_by, created_at
FROM plugin_instrumentation WHERE id = ?`, id)
return scanInstrumentation(row)
}
func (r Repository) ListInstrumentation(ctx context.Context) ([]InstrumentationRecord, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, name, version, profile, generated_diff_hash, provenance_json, conformance_json,
benchmark_json, smoke_json, runbook_rollback, status, created_by, created_at
FROM plugin_instrumentation ORDER BY created_at DESC, id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var records []InstrumentationRecord
for rows.Next() {
record, err := scanInstrumentation(rows)
if err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.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 {
@@ -1320,6 +1587,16 @@ type rowScanner interface {
Scan(dest ...any) error
}
func scanPluginServiceState(row rowScanner) (PluginServiceState, error) {
var state PluginServiceState
err := row.Scan(
&state.DesiredMode, &state.ActiveMode, &state.AppliedAt, &state.LiveMigration,
&state.LastError, &state.UpdatedBy, &state.UpdatedAt,
)
state = normalizePluginServiceState(state)
return state, err
}
func scanArtifact(row rowScanner) (ArtifactRecord, error) {
var artifact ArtifactRecord
err := row.Scan(
@@ -1427,6 +1704,48 @@ func scanBenchmark(row rowScanner) (BenchmarkRecord, error) {
return record, err
}
func scanRepositoryImport(row rowScanner) (RepositoryImportRecord, error) {
var record RepositoryImportRecord
err := row.Scan(
&record.ID, &record.RepositoryType, &record.IndexPath, &record.RepositoryName,
&record.CandidateID, &record.PluginID, &record.Version, &record.ArtifactID,
&record.PackageSHA256, &record.TrustPolicy, &record.AdmissionJSON,
&record.ImportedBy, &record.CreatedAt,
)
return record, err
}
func scanSupplyChainAssessment(row rowScanner) (SupplyChainAssessment, error) {
var assessment SupplyChainAssessment
var issuesJSON, signatureJSON, sbomJSON, licenseJSON, advisoryJSON, metadataJSON string
err := row.Scan(
&assessment.ID, &assessment.PluginID, &assessment.ArtifactID, &assessment.Status,
&issuesJSON, &signatureJSON, &sbomJSON, &licenseJSON, &advisoryJSON,
&metadataJSON, &assessment.CreatedBy, &assessment.CreatedAt,
)
if err != nil {
return assessment, err
}
_ = json.Unmarshal([]byte(defaultJSONArray(issuesJSON)), &assessment.Issues)
assessment.Signature = jsonMap(signatureJSON)
assessment.SBOM = jsonMap(sbomJSON)
assessment.License = jsonMap(licenseJSON)
assessment.Advisory = jsonMap(advisoryJSON)
assessment.Metadata = jsonMap(metadataJSON)
return assessment, nil
}
func scanInstrumentation(row rowScanner) (InstrumentationRecord, error) {
var record InstrumentationRecord
err := row.Scan(
&record.ID, &record.Name, &record.Version, &record.Profile, &record.GeneratedDiffHash,
&record.ProvenanceJSON, &record.ConformanceJSON, &record.BenchmarkJSON,
&record.SmokeJSON, &record.RunbookRollback, &record.Status, &record.CreatedBy,
&record.CreatedAt,
)
return record, err
}
func marshalDefaultObject(value any) (string, error) {
if value == nil {
return "{}", nil
@@ -1455,6 +1774,28 @@ func defaultJSONArray(value string) string {
return value
}
func jsonMap(value string) map[string]any {
var out map[string]any
if json.Unmarshal([]byte(defaultJSONObject(value)), &out) != nil {
return nil
}
return out
}
func normalizePluginServiceState(state PluginServiceState) PluginServiceState {
if state.DesiredMode == "" {
state.DesiredMode = PluginServiceModeInProcess
}
if state.ActiveMode == "" {
state.ActiveMode = PluginServiceModeInProcess
}
if state.LiveMigration == "" {
state.LiveMigration = PluginMigrationDrainOnly
}
state.RestartRequired = state.DesiredMode != state.ActiveMode
return state
}
func boolInt(value bool) int {
if value {
return 1

View File

@@ -18,10 +18,25 @@ const (
ArtifactTypeBinary = "binary"
ArtifactTypeSource = "source"
RuntimeGoPlugin = "go-plugin"
RuntimeBuiltin = "builtin"
RuntimeSandbox = "sandbox-process"
RuntimeWASM = "wasm"
RuntimeEntry = "plugin.so"
RuntimeWASMEntry = "plugin.wasm"
SourceBuildEntry = "."
ExtensionUpstreamConnect = "upstream.connect/v1"
ExtensionUpstreamConnect = "upstream.connect/v1"
ExtensionRouteResolve = "route.resolve/v1"
ExtensionRouteResolver = "route.resolver/v1"
ExtensionRuleEvaluate = "rule.evaluate/v1"
ExtensionConfigValidate = "config.validate/v1"
ExtensionStatusPing = "status.ping/v1"
ExtensionConnectionFilter = "connection.filter/v1"
ExtensionHandshakeFilter = "handshake.filter/v1"
ExtensionEventSubscriber = "event.subscriber/v1"
ExtensionProvider = "provider/v1"
ExtensionAuthProvider = "auth.provider/v1"
ExtensionAdminAuthProvider = "admin.auth.provider/v1"
UpstreamModeDialer = "dialer"
UpstreamModeProtocolProxy = "protocol-proxy"
@@ -54,6 +69,26 @@ const (
RuntimeDisabled = "disabled"
RuntimeDraining = "draining"
PluginServiceModeInProcess = "in-process"
PluginServiceModeGoPluginProcess = "go-plugin-process"
PluginServiceModeSandboxProcess = "sandbox-process"
PluginMigrationDrainOnly = "drain-only"
PluginMigrationFDLive = "fd-live"
PluginMigrationFDLiveSHM = "fd-live-shm"
RepositoryTypeOfficial = "official"
RepositoryTypeInternal = "internal"
RepositoryTypeFile = "file"
RepositoryTypeURL = "url"
SupplyChainStatusAllowed = "allowed"
SupplyChainStatusBlocked = "blocked"
SupplyChainStatusWarning = "warning"
InstrumentationStatusAvailable = "available"
InstrumentationStatusBlocked = "blocked"
PolicyProfileDev = "dev"
PolicyProfileStaging = "staging"
PolicyProfileProd = "prod"
@@ -82,23 +117,25 @@ const (
AdvisoryStatusRevoked = "revoked"
AdvisoryStatusAcked = "acknowledged"
DefaultPriority = 100
DefaultHandlerTimeout = 3 * time.Second
DefaultManifestMaxBytes = 256 * 1024
DefaultPackageMaxBytes = 64 * 1024 * 1024
DefaultPackageMaxEntries = 2048
DefaultExtractedMaxBytes = 256 * 1024 * 1024
DefaultNonRuntimeMaxBytes = 16 * 1024 * 1024
DefaultInitialWriteTimeout = time.Second
DefaultExternalTimeout = 5 * time.Second
DefaultBuildLogMaxBytes = 64 * 1024
DefaultEventQueueLimit = 1000
DefaultEventRecentLimit = 1000
DefaultLabelValueMaxBytes = 64
DefaultPluginDataQuota = 16 * 1024 * 1024
DefaultPluginDataKeyLimit = 256 * 1024
DefaultPluginFileQuota = 32 * 1024 * 1024
DefaultLogRecentLimit = 500
DefaultPriority = 100
DefaultHandlerTimeout = 3 * time.Second
DefaultSubscriberRetryDelay = 100 * time.Millisecond
DefaultSubscriberMaxRetry = 3
DefaultManifestMaxBytes = 256 * 1024
DefaultPackageMaxBytes = 64 * 1024 * 1024
DefaultPackageMaxEntries = 2048
DefaultExtractedMaxBytes = 256 * 1024 * 1024
DefaultNonRuntimeMaxBytes = 16 * 1024 * 1024
DefaultInitialWriteTimeout = time.Second
DefaultExternalTimeout = 5 * time.Second
DefaultBuildLogMaxBytes = 64 * 1024
DefaultEventQueueLimit = 1000
DefaultEventRecentLimit = 1000
DefaultLabelValueMaxBytes = 64
DefaultPluginDataQuota = 16 * 1024 * 1024
DefaultPluginDataKeyLimit = 256 * 1024
DefaultPluginFileQuota = 32 * 1024 * 1024
DefaultLogRecentLimit = 500
)
var (
@@ -161,6 +198,7 @@ type ExtensionPoint struct {
type RuntimeLimits struct {
HandlerTimeoutMS int `json:"handler_timeout_ms"`
InitialWriteTimeoutMS int `json:"initial_write_timeout_ms"`
MemoryBytes int `json:"memory_bytes,omitempty"`
}
type SecretSpec struct {
@@ -231,19 +269,56 @@ type FileStoreSpec struct {
type CapabilitySummary struct {
UpstreamConnect UpstreamConnectCapability `json:"upstream_connect,omitempty"`
Route RouteCapability `json:"route,omitempty"`
Status StatusCapability `json:"status,omitempty"`
Middleware MiddlewareCapability `json:"middleware,omitempty"`
Providers []ProviderCapability `json:"providers,omitempty"`
EventSubscriber EventSubscriberCapability `json:"event_subscriber,omitempty"`
Minecraft *MinecraftCapability `json:"minecraft,omitempty"`
Events []EventSpec `json:"events,omitempty"`
CustomMetrics []MetricSpec `json:"custom_metrics,omitempty"`
ExternalDeps []ExternalSpec `json:"external_dependencies,omitempty"`
DataStores []DataStoreSpec `json:"data_stores,omitempty"`
FileStores []FileStoreSpec `json:"file_stores,omitempty"`
Runtime RuntimeCapability `json:"runtime,omitempty"`
Raw json.RawMessage `json:"raw,omitempty"`
}
type RuntimeCapability struct {
RequiredCapabilities []string `json:"required_capabilities,omitempty"`
RequiredFeatures []string `json:"required_features,omitempty"`
}
type UpstreamConnectCapability struct {
Mode string `json:"mode,omitempty"`
}
type RouteCapability struct {
CacheTTLMS int `json:"cache_ttl_ms,omitempty"`
}
type StatusCapability struct {
Hosts []string `json:"hosts,omitempty"`
}
type MiddlewareCapability struct {
FailPolicy string `json:"fail_policy,omitempty"`
}
type ProviderCapability struct {
Type string `json:"type,omitempty"`
Name string `json:"name,omitempty"`
Priority int `json:"priority,omitempty"`
Fallback bool `json:"fallback,omitempty"`
Dependencies []string `json:"dependencies,omitempty"`
}
type EventSubscriberCapability struct {
Mode string `json:"mode,omitempty"`
QueueLimit int `json:"queue_limit,omitempty"`
MaxRetry int `json:"max_retry,omitempty"`
}
type MinecraftCapability struct {
ProtocolVersions MinecraftProtocolVersions `json:"protocol_versions,omitempty"`
States map[string]string `json:"states,omitempty"`
@@ -627,6 +702,105 @@ type BuildRequest struct {
VendorRequired bool `json:"vendor_required"`
}
type PluginServiceState struct {
DesiredMode string `json:"desired_mode"`
ActiveMode string `json:"active_mode"`
AppliedAt int64 `json:"applied_at"`
RestartRequired bool `json:"restart_required"`
LiveMigration string `json:"live_migration"`
LastError string `json:"last_error"`
UpdatedBy string `json:"updated_by"`
UpdatedAt int64 `json:"updated_at"`
}
type PluginServiceStatus struct {
Service PluginServiceState `json:"service"`
Hosts []PluginHostRuntimeSummary `json:"hosts"`
}
type PluginHostRuntimeSummary struct {
PluginID string `json:"plugin_id"`
ArtifactID string `json:"artifact_id"`
State string `json:"state"`
DrainMode string `json:"drain_mode"`
CrashLoop bool `json:"crash_loop"`
CrashCount int `json:"crash_count"`
LastError string `json:"last_error"`
StartedAt int64 `json:"started_at"`
DrainingAt int64 `json:"draining_at"`
ExitedAt int64 `json:"exited_at"`
LastCrashAt int64 `json:"last_crash_at"`
}
type RepositoryImportRequest struct {
RepositoryType string `json:"repository_type"`
IndexPath string `json:"index_path"`
ArtifactID string `json:"artifact_id"`
PluginID string `json:"plugin_id"`
Version string `json:"version"`
TrustPolicy string `json:"trust_policy"`
}
type RepositoryImportRecord struct {
ID int64 `json:"id"`
RepositoryType string `json:"repository_type"`
IndexPath string `json:"index_path"`
RepositoryName string `json:"repository_name"`
CandidateID string `json:"candidate_id"`
PluginID string `json:"plugin_id"`
Version string `json:"version"`
ArtifactID string `json:"artifact_id"`
PackageSHA256 string `json:"package_sha256"`
TrustPolicy string `json:"trust_policy"`
AdmissionJSON string `json:"admission_json"`
ImportedBy string `json:"imported_by"`
CreatedAt int64 `json:"created_at"`
}
type SupplyChainAssessment struct {
ID int64 `json:"id"`
PluginID string `json:"plugin_id"`
ArtifactID string `json:"artifact_id"`
Status string `json:"status"`
Issues []GovernanceIssue `json:"issues"`
Signature map[string]any `json:"signature,omitempty"`
SBOM map[string]any `json:"sbom,omitempty"`
License map[string]any `json:"license,omitempty"`
Advisory map[string]any `json:"advisory,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
CreatedBy string `json:"created_by"`
CreatedAt int64 `json:"created_at"`
}
type InstrumentationRecord struct {
ID int64 `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Profile string `json:"profile"`
GeneratedDiffHash string `json:"generated_diff_hash"`
ProvenanceJSON string `json:"provenance_json"`
ConformanceJSON string `json:"conformance_json"`
BenchmarkJSON string `json:"benchmark_json"`
SmokeJSON string `json:"smoke_json"`
RunbookRollback string `json:"runbook_rollback"`
Status string `json:"status"`
CreatedBy string `json:"created_by"`
CreatedAt int64 `json:"created_at"`
}
type InstrumentationRequest struct {
Name string `json:"name"`
Version string `json:"version"`
Profile string `json:"profile"`
GeneratedDiffHash string `json:"generated_diff_hash"`
Provenance map[string]any `json:"provenance"`
Conformance map[string]any `json:"conformance"`
Benchmark map[string]any `json:"benchmark"`
Smoke map[string]any `json:"smoke"`
RunbookRollback string `json:"runbook_rollback"`
Status string `json:"status"`
}
type GCCandidate struct {
Kind string `json:"kind"`
ID string `json:"id"`
@@ -652,8 +826,14 @@ type ConfigSnapshot struct {
}
type DispatchPlan struct {
Handlers []DispatchHandlerSummary `json:"handlers"`
UpdatedAt int64 `json:"updated_at"`
Handlers []DispatchHandlerSummary `json:"handlers"`
Routes []DispatchHandlerSummary `json:"routes"`
Statuses []DispatchHandlerSummary `json:"statuses"`
Middleware []DispatchHandlerSummary `json:"middleware"`
Subscribers []DispatchHandlerSummary `json:"subscribers"`
Providers []ProviderSummary `json:"providers"`
RouteCache []RouteDecisionSummary `json:"route_cache"`
UpdatedAt int64 `json:"updated_at"`
}
type DispatchHandlerSummary struct {
@@ -720,10 +900,39 @@ type EventSummary struct {
}
type EventQueueSummary struct {
Limit int `json:"limit"`
Queued int `json:"queued"`
Dropped uint64 `json:"dropped"`
DeadLetters uint64 `json:"dead_letters"`
Limit int `json:"limit"`
Queued int `json:"queued"`
Dropped uint64 `json:"dropped"`
DeadLetters uint64 `json:"dead_letters"`
SubscriberQueued uint64 `json:"subscriber_queued"`
SubscriberDropped uint64 `json:"subscriber_dropped"`
SubscriberDeadLetters uint64 `json:"subscriber_dead_letters"`
}
type RouteDecisionSummary struct {
Host string `json:"host"`
Action string `json:"action"`
Upstream string `json:"upstream,omitempty"`
ProviderID string `json:"provider_id,omitempty"`
Source string `json:"source"`
Reason string `json:"reason,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
CreatedAt int64 `json:"created_at"`
ExpiresAt int64 `json:"expires_at,omitempty"`
}
type ProviderSummary struct {
PluginID string `json:"plugin_id"`
ArtifactID string `json:"artifact_id"`
ExtensionPoint string `json:"extension_point"`
Type string `json:"type"`
Name string `json:"name"`
Priority int `json:"priority"`
Fallback bool `json:"fallback"`
Dependencies []string `json:"dependencies,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
type CustomMetricSummary struct {
@@ -946,3 +1155,48 @@ func (g *Gateway) UpstreamConnectHandler() (api.HookHandler[api.UpstreamConnectA
handler, ok := g.hooks[api.HookUpstreamConnect.Key()].(api.HookHandler[api.UpstreamConnectAcceptor, api.UpstreamConnectHandler])
return handler, ok
}
func (g *Gateway) RouteResolveHandler() (api.HookHandler[api.RouteResolveAcceptor, api.RouteResolveHandler], bool) {
handler, ok := g.hooks[api.HookRouteResolve.Key()].(api.HookHandler[api.RouteResolveAcceptor, api.RouteResolveHandler])
return handler, ok
}
func (g *Gateway) RouteResolverHandler() (api.HookHandler[api.RouteResolveAcceptor, api.RouteResolveHandler], bool) {
handler, ok := g.hooks[api.HookRouteResolver.Key()].(api.HookHandler[api.RouteResolveAcceptor, api.RouteResolveHandler])
return handler, ok
}
func (g *Gateway) StatusPingHandler() (api.HookHandler[api.StatusPingAcceptor, api.StatusPingHandler], bool) {
handler, ok := g.hooks[api.HookStatusPing.Key()].(api.HookHandler[api.StatusPingAcceptor, api.StatusPingHandler])
return handler, ok
}
func (g *Gateway) ConnectionFilterHandler() (api.HookHandler[api.ConnectionFilterAcceptor, api.ConnectionFilterHandler], bool) {
handler, ok := g.hooks[api.HookConnectionFilter.Key()].(api.HookHandler[api.ConnectionFilterAcceptor, api.ConnectionFilterHandler])
return handler, ok
}
func (g *Gateway) HandshakeFilterHandler() (api.HookHandler[api.HandshakeFilterAcceptor, api.HandshakeFilterHandler], bool) {
handler, ok := g.hooks[api.HookHandshakeFilter.Key()].(api.HookHandler[api.HandshakeFilterAcceptor, api.HandshakeFilterHandler])
return handler, ok
}
func (g *Gateway) EventSubscriberHandler() (api.HookHandler[api.EventSubscriberAcceptor, api.EventSubscriberHandler], bool) {
handler, ok := g.hooks[api.HookEventSubscriber.Key()].(api.HookHandler[api.EventSubscriberAcceptor, api.EventSubscriberHandler])
return handler, ok
}
func (g *Gateway) ProviderHandler() (api.HookHandler[api.ProviderAcceptor, api.ProviderHandler], bool) {
handler, ok := g.hooks[api.HookProvider.Key()].(api.HookHandler[api.ProviderAcceptor, api.ProviderHandler])
return handler, ok
}
func (g *Gateway) AuthProviderHandler() (api.HookHandler[api.ProviderAcceptor, api.ProviderHandler], bool) {
handler, ok := g.hooks[api.HookAuthProvider.Key()].(api.HookHandler[api.ProviderAcceptor, api.ProviderHandler])
return handler, ok
}
func (g *Gateway) AdminAuthProviderHandler() (api.HookHandler[api.ProviderAcceptor, api.ProviderHandler], bool) {
handler, ok := g.hooks[api.HookAdminAuthProvider.Key()].(api.HookHandler[api.ProviderAcceptor, api.ProviderHandler])
return handler, ok
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"net"
"time"
"unsafe"
)
@@ -52,9 +53,148 @@ type (
UpstreamConnectAcceptor func(UpstreamConnectRequest) bool
UpstreamConnectHandler func(UpstreamConnectRequest) (net.Conn, error)
RouteResolveRequest struct {
Context context.Context `json:"-"`
Host string `json:"host"`
RawServerHost string `json:"raw_server_host,omitempty"`
SourceAddr string `json:"source_addr,omitempty"`
ProtocolVersion int `json:"protocol_version,omitempty"`
NextState int `json:"next_state,omitempty"`
FallbackUpstream string `json:"fallback_upstream,omitempty"`
FallbackHit bool `json:"fallback_hit"`
Refresh bool `json:"refresh,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
Handshake UpstreamHandshakeRef `json:"handshake,omitempty"`
}
UpstreamHandshakeRef struct {
ServerHost string `json:"server_host,omitempty"`
RawServerHost string `json:"raw_server_host,omitempty"`
ProtocolVersion int `json:"protocol_version,omitempty"`
NextState int `json:"next_state,omitempty"`
}
RouteDecision struct {
Action string `json:"action"`
Upstream string `json:"upstream,omitempty"`
Host string `json:"host,omitempty"`
Reason string `json:"reason,omitempty"`
ProviderID string `json:"provider_id,omitempty"`
CacheTTL time.Duration `json:"cache_ttl,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
Explanation string `json:"explanation,omitempty"`
}
RouteResolveAcceptor func(RouteResolveRequest) bool
RouteResolveHandler func(RouteResolveRequest) (RouteDecision, error)
StatusPingRequest struct {
Context context.Context `json:"-"`
Host string `json:"host"`
RawServerHost string `json:"raw_server_host,omitempty"`
SourceAddr string `json:"source_addr,omitempty"`
ProtocolVersion int `json:"protocol_version,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
StatusPingResponse struct {
MOTD string `json:"motd,omitempty"`
Favicon string `json:"favicon,omitempty"`
OnlinePlayers int `json:"online_players,omitempty"`
MaxPlayers int `json:"max_players,omitempty"`
VersionText string `json:"version_text,omitempty"`
ProtocolVersion int `json:"protocol_version,omitempty"`
Maintenance bool `json:"maintenance,omitempty"`
MaintenanceWindow string `json:"maintenance_window,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
StatusPingAcceptor func(StatusPingRequest) bool
StatusPingHandler func(StatusPingRequest) (StatusPingResponse, error)
FilterDecision struct {
Allow bool `json:"allow"`
Reject bool `json:"reject,omitempty"`
Reason string `json:"reason,omitempty"`
FailPolicy string `json:"fail_policy,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
ConnectionFilterRequest struct {
Context context.Context `json:"-"`
SourceAddr string `json:"source_addr,omitempty"`
Transport string `json:"transport,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
ConnectionFilterAcceptor func(ConnectionFilterRequest) bool
ConnectionFilterHandler func(ConnectionFilterRequest) (FilterDecision, error)
HandshakeFilterRequest struct {
Context context.Context `json:"-"`
SourceAddr string `json:"source_addr,omitempty"`
ServerHost string `json:"server_host"`
RawServerHost string `json:"raw_server_host,omitempty"`
ProtocolVersion int `json:"protocol_version,omitempty"`
NextState int `json:"next_state,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
HandshakeFilterDecision struct {
FilterDecision
RewriteHost string `json:"rewrite_host,omitempty"`
}
HandshakeFilterAcceptor func(HandshakeFilterRequest) bool
HandshakeFilterHandler func(HandshakeFilterRequest) (HandshakeFilterDecision, error)
EventDeliveryRequest struct {
Context context.Context `json:"-"`
PluginID string `json:"plugin_id"`
Name string `json:"name"`
Fields map[string]string `json:"fields,omitempty"`
TraceID string `json:"trace_id,omitempty"`
ConnectionID string `json:"connection_id,omitempty"`
Attempt int `json:"attempt"`
Mode string `json:"mode,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
EventDeliveryResult struct {
OK bool `json:"ok"`
Retry bool `json:"retry,omitempty"`
Reason string `json:"reason,omitempty"`
}
EventSubscriberAcceptor func(EventDeliveryRequest) bool
EventSubscriberHandler func(EventDeliveryRequest) (EventDeliveryResult, error)
ProviderRegistration struct {
Type string `json:"type"`
Name string `json:"name"`
Priority int `json:"priority,omitempty"`
Fallback bool `json:"fallback,omitempty"`
Dependencies []string `json:"dependencies,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
ProviderAcceptor func(ProviderRegistration) bool
ProviderHandler func() (ProviderRegistration, error)
)
var (
RouteDecisionPass = "pass"
RouteDecisionOverride = "override"
RouteDecisionFallback = "fallback"
RouteDecisionReject = "reject"
DeliveryBestEffort = "best_effort"
DeliveryAtLeastOnce = "at_least_once"
FailPolicyOpen = "fail_open"
FailPolicyClose = "fail_closed"
HookUpstreamConnect = HookType[
UpstreamConnectAcceptor,
UpstreamConnectHandler,
@@ -68,6 +208,69 @@ var (
]{
key: "upstream",
}
HookRouteResolve = HookType[
RouteResolveAcceptor,
RouteResolveHandler,
]{
key: "route.resolve/v1",
}
HookRouteResolver = HookType[
RouteResolveAcceptor,
RouteResolveHandler,
]{
key: "route.resolver/v1",
}
HookStatusPing = HookType[
StatusPingAcceptor,
StatusPingHandler,
]{
key: "status.ping/v1",
}
HookConnectionFilter = HookType[
ConnectionFilterAcceptor,
ConnectionFilterHandler,
]{
key: "connection.filter/v1",
}
HookHandshakeFilter = HookType[
HandshakeFilterAcceptor,
HandshakeFilterHandler,
]{
key: "handshake.filter/v1",
}
HookEventSubscriber = HookType[
EventSubscriberAcceptor,
EventSubscriberHandler,
]{
key: "event.subscriber/v1",
}
HookProvider = HookType[
ProviderAcceptor,
ProviderHandler,
]{
key: "provider/v1",
}
HookAuthProvider = HookType[
ProviderAcceptor,
ProviderHandler,
]{
key: "auth.provider/v1",
}
HookAdminAuthProvider = HookType[
ProviderAcceptor,
ProviderHandler,
]{
key: "admin.auth.provider/v1",
}
)
func (h HookType[Accept, Handler]) Key() string {

View File

@@ -0,0 +1,214 @@
package rulepolicy
import (
"net"
"strings"
"sync"
"time"
"github.com/tursom/mc-gateway/plugin/api"
)
type Plugin struct {
api.AbstractPlugin
mu sync.Mutex
config Config
bucket map[string]rateBucket
}
type Config struct {
HostRewrite map[string]string `json:"host_rewrite,omitempty"`
UpstreamRewrite map[string]string `json:"upstream_rewrite,omitempty"`
SourceAllowCIDR []string `json:"source_allow_cidr,omitempty"`
SourceDenyCIDR []string `json:"source_deny_cidr,omitempty"`
RateLimit RateLimitConfig `json:"rate_limit,omitempty"`
Maintenance MaintenanceConfig `json:"maintenance,omitempty"`
}
type RateLimitConfig struct {
Requests int `json:"requests,omitempty"`
Window string `json:"window,omitempty"`
}
type MaintenanceConfig struct {
Enabled bool `json:"enabled,omitempty"`
Hosts []string `json:"hosts,omitempty"`
MOTD string `json:"motd,omitempty"`
Version string `json:"version,omitempty"`
Window string `json:"window,omitempty"`
StatusByHost map[string]string `json:"status_by_host,omitempty"`
}
type rateBucket struct {
windowStart time.Time
count int
}
func New() *Plugin {
return &Plugin{bucket: make(map[string]rateBucket)}
}
func (p *Plugin) NewConfigObj() any {
return &Config{}
}
func (p *Plugin) ReloadConfig(config any) error {
next, _ := config.(*Config)
if next == nil {
next = &Config{}
}
p.mu.Lock()
defer p.mu.Unlock()
p.config = *next
if p.bucket == nil {
p.bucket = make(map[string]rateBucket)
}
return nil
}
func (p *Plugin) Init(gateway api.Gateway) error {
if err := api.RegisterHookHandler(gateway, api.HookConnectionFilter, p.acceptConnection, p.filterConnection); err != nil {
return err
}
if err := api.RegisterHookHandler(gateway, api.HookHandshakeFilter, p.acceptHandshake, p.filterHandshake); err != nil {
return err
}
if err := api.RegisterHookHandler(gateway, api.HookRouteResolve, p.acceptRoute, p.resolveRoute); err != nil {
return err
}
return api.RegisterHookHandler(gateway, api.HookStatusPing, p.acceptStatus, p.statusPing)
}
func (p *Plugin) acceptConnection(api.ConnectionFilterRequest) bool { return true }
func (p *Plugin) filterConnection(req api.ConnectionFilterRequest) (api.FilterDecision, error) {
cfg := p.snapshot()
ip := sourceIP(req.SourceAddr)
if ip != nil {
if cidrMatches(cfg.SourceDenyCIDR, ip) {
return api.FilterDecision{Allow: false, Reject: true, Reason: "source denied by CIDR policy"}, nil
}
if len(cfg.SourceAllowCIDR) > 0 && !cidrMatches(cfg.SourceAllowCIDR, ip) {
return api.FilterDecision{Allow: false, Reject: true, Reason: "source not allowed by CIDR policy"}, nil
}
}
if cfg.RateLimit.Requests > 0 && p.rateLimited(req.SourceAddr, cfg.RateLimit) {
return api.FilterDecision{Allow: false, Reject: true, Reason: "source rate limited"}, nil
}
return api.FilterDecision{Allow: true}, nil
}
func (p *Plugin) acceptHandshake(api.HandshakeFilterRequest) bool { return true }
func (p *Plugin) filterHandshake(req api.HandshakeFilterRequest) (api.HandshakeFilterDecision, error) {
cfg := p.snapshot()
if rewritten := cfg.HostRewrite[strings.ToLower(req.ServerHost)]; rewritten != "" {
return api.HandshakeFilterDecision{
FilterDecision: api.FilterDecision{Allow: true, Reason: "host rewrite"},
RewriteHost: rewritten,
}, nil
}
return api.HandshakeFilterDecision{FilterDecision: api.FilterDecision{Allow: true}}, nil
}
func (p *Plugin) acceptRoute(api.RouteResolveRequest) bool { return true }
func (p *Plugin) resolveRoute(req api.RouteResolveRequest) (api.RouteDecision, error) {
cfg := p.snapshot()
if upstream := cfg.UpstreamRewrite[strings.ToLower(req.Host)]; upstream != "" {
return api.RouteDecision{
Action: api.RouteDecisionOverride,
Upstream: upstream,
ProviderID: "official.rule-policy",
Reason: "upstream rewrite rule",
CacheTTL: time.Minute,
}, nil
}
return api.RouteDecision{Action: api.RouteDecisionPass}, nil
}
func (p *Plugin) acceptStatus(req api.StatusPingRequest) bool {
cfg := p.snapshot()
if !cfg.Maintenance.Enabled && len(cfg.Maintenance.StatusByHost) == 0 {
return false
}
if len(cfg.Maintenance.Hosts) == 0 {
return true
}
host := strings.ToLower(req.Host)
for _, item := range cfg.Maintenance.Hosts {
if strings.ToLower(item) == host {
return true
}
}
return false
}
func (p *Plugin) statusPing(req api.StatusPingRequest) (api.StatusPingResponse, error) {
cfg := p.snapshot()
motd := cfg.Maintenance.MOTD
if hostMOTD := cfg.Maintenance.StatusByHost[strings.ToLower(req.Host)]; hostMOTD != "" {
motd = hostMOTD
}
if motd == "" {
motd = "Maintenance"
}
version := cfg.Maintenance.Version
if version == "" {
version = "Maintenance"
}
return api.StatusPingResponse{
MOTD: motd,
VersionText: version,
ProtocolVersion: req.ProtocolVersion,
Maintenance: cfg.Maintenance.Enabled,
MaintenanceWindow: cfg.Maintenance.Window,
}, nil
}
func (p *Plugin) snapshot() Config {
p.mu.Lock()
defer p.mu.Unlock()
return p.config
}
func (p *Plugin) rateLimited(key string, cfg RateLimitConfig) bool {
window := time.Second
if cfg.Window != "" {
if parsed, err := time.ParseDuration(cfg.Window); err == nil && parsed > 0 {
window = parsed
}
}
now := time.Now()
p.mu.Lock()
defer p.mu.Unlock()
bucket := p.bucket[key]
if bucket.windowStart.IsZero() || now.Sub(bucket.windowStart) >= window {
bucket = rateBucket{windowStart: now}
}
bucket.count++
p.bucket[key] = bucket
return bucket.count > cfg.Requests
}
func cidrMatches(ranges []string, ip net.IP) bool {
for _, raw := range ranges {
_, network, err := net.ParseCIDR(strings.TrimSpace(raw))
if err != nil {
continue
}
if network.Contains(ip) {
return true
}
}
return false
}
func sourceIP(addr string) net.IP {
host, _, err := net.SplitHostPort(addr)
if err != nil {
host = addr
}
return net.ParseIP(host)
}

View File

@@ -2,6 +2,7 @@ package protocol
import (
"bytes"
"encoding/json"
"errors"
"strings"
)
@@ -113,6 +114,21 @@ func ReadString(buf []byte) (string, int, error) {
return readString(buf)
}
func StatusResponsePacket(value any) ([]byte, error) {
data, err := json.Marshal(value)
if err != nil {
return nil, err
}
var payload bytes.Buffer
payload.Write(encodeVarInt(0))
payload.Write(encodeVarInt(len(data)))
payload.Write(data)
var out bytes.Buffer
out.Write(encodeVarInt(payload.Len()))
out.Write(payload.Bytes())
return out.Bytes(), nil
}
func readPacket(buf []byte) ([]byte, int, error) {
length, n, err := readVarInt(buf)
if err != nil {