Compare commits

...

3 Commits

Author SHA1 Message Date
tursom
f508ecc1b9 docs: 补充中文代码注释
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
Docker Image / docker (push) Has been cancelled
Go / merge-artifacts (push) Has been cancelled
2026-06-27 20:15:29 +08:00
tursom
7a5664ab89 feat(plugin): support multi-format manifest sources 2026-06-27 18:28:03 +08:00
tursom
e4dfcb62cb feat(plugin): add development toolchain 2026-06-27 10:19:57 +08:00
160 changed files with 5521 additions and 413 deletions

View File

@@ -1,3 +1,5 @@
# Dockerfile 构建网关二进制、编译管理前端,并打包带 SQLite 友好默认值的运行镜像。
# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM node:24.11.1-alpine AS admin-frontend

View File

@@ -1,3 +1,5 @@
// cmd/gateway/admin_api.go 组装 Admin API 的共享依赖,并提供嵌入式控制台使用的顶层 HTTP 路由。
package main
import (
@@ -7,6 +9,8 @@ import (
)
func newAdminAPIHandler() http.HandlerFunc {
// Admin API 的路径解析放在 internal/adminhttp 中,主包只提供各业务 handler。
// 这样测试可以复用同一套路由表,而不会依赖真实监听器。
return adminhttp.NewAPIHandler(adminStartup.AdminAPIPrefix, adminhttp.APIHandlers{
SetupStatus: handleAdminSetupStatus,
Setup: handleAdminSetup,
@@ -29,6 +33,8 @@ func newAdminAPIHandler() http.HandlerFunc {
AuditLogs: handleAdminAuditLogs,
// 插件相关接口数量较多,统一在这里接入,确保嵌入式 UI 和远程 CLI
// 看到的是同一套 Admin API 行为。
PluginArtifacts: handleAdminPluginArtifacts,
PluginArtifact: handleAdminPluginArtifact,
PluginSources: handleAdminPluginSources,

View File

@@ -1,3 +1,5 @@
// cmd/gateway/admin_api_test.go 包含用于约束 admin api 行为的测试。
package main
import (

View File

@@ -1,3 +1,5 @@
// cmd/gateway/admin_audit.go 把 HTTP 请求上下文转换为持久化审计记录,用于追踪管理端变更。
package main
import (

View File

@@ -1,3 +1,5 @@
// cmd/gateway/admin_auth_handlers.go 处理初始化、登录、登出和当前会话查询等嵌入式管理端认证接口。
package main
import (

View File

@@ -1,3 +1,5 @@
// cmd/gateway/admin_frontend/src/alerts.ts 集中处理告警展示,让异步界面流程可以一致地清空或显示错误。
import { el } from "./dom.js";
import { localizeMessage } from "./i18n.js";

View File

@@ -1,3 +1,5 @@
// cmd/gateway/admin_frontend/src/api.ts 封装 fetch统一处理 Admin API 前缀、令牌、JSON 编码和错误返回。
import { state } from "./state.js";
interface APIOptions {

View File

@@ -1,3 +1,5 @@
// cmd/gateway/admin_frontend/src/config.ts 读取嵌入式管理端 HTML 壳注入的运行时配置。
import type { RuntimeConfig } from "./types.js";
declare global {

View File

@@ -1,3 +1,5 @@
// cmd/gateway/admin_frontend/src/dom.ts 集中 DOM 辅助方法,包括查询、转义、徽标、去抖和表单取值。
export function el<T extends HTMLElement = HTMLElement>(id: string): T {
const node = document.getElementById(id);
if (!node) {

View File

@@ -1,3 +1,5 @@
// cmd/gateway/admin_frontend/src/i18n.ts 保存嵌入式管理端翻译字典,并提供语言切换辅助方法。
import { el } from "./dom.js";
import { languageStorageKey, state } from "./state.js";

View File

@@ -1,3 +1,5 @@
// cmd/gateway/admin_frontend/src/main.ts 启动嵌入式管理端,选择初始化/登录/应用视图,并协调按角色加载数据。
import { api } from "./api.js";
import { showAlert } from "./alerts.js";
import { runtimeConfig } from "./config.js";
@@ -15,12 +17,14 @@ import { loadStatus } from "./views/status.js";
import { loadUsers, openUserDialog, renderUsers, saveUser } from "./views/users.js";
async function boot(): Promise<void> {
// API 前缀由后端嵌入到 HTML 中,前端启动时先读取它,避免部署在子路径时写死地址。
state.apiBase = runtimeConfig().apiPrefix;
initializeLanguage();
bindEvents();
try {
const setup = await api<SetupStatus>("/setup");
if (setup.required) {
// 没有任何管理账号时只展示初始化界面,不尝试加载其他运行态数据。
setView("setupView");
setSubtitle("setupSubtitle");
return;
@@ -30,6 +34,7 @@ async function boot(): Promise<void> {
}
if (!state.token) {
// token 保存在本地状态中;没有 token 时直接进入登录视图。
setView("loginView");
setSubtitle("login");
return;
@@ -39,6 +44,7 @@ async function boot(): Promise<void> {
state.user = await api<User>("/me");
await showApp();
} catch {
// token 失效时清空本地状态,避免后续 API 调用持续带着过期凭证。
setToken("");
setView("loginView");
setSubtitle("login");
@@ -46,6 +52,7 @@ async function boot(): Promise<void> {
}
function bindEvents(): void {
// 所有顶层事件在启动时绑定一次,视图重渲染只更新内容区域。
el<HTMLSelectElement>("languageSelect").addEventListener("change", (event) => {
changeLanguage((event.currentTarget as HTMLSelectElement).value, rerenderCurrentView);
});
@@ -75,6 +82,7 @@ async function submitSetup(event: SubmitEvent): Promise<void> {
event.preventDefault();
const form = new FormData(event.currentTarget as HTMLFormElement);
try {
// 初始化只创建首个管理员账号,创建成功后仍要求用户走登录流程获取会话 token。
await api("/setup", {
method: "POST",
body: {
@@ -94,6 +102,7 @@ async function submitLogin(event: SubmitEvent): Promise<void> {
event.preventDefault();
const form = new FormData(event.currentTarget as HTMLFormElement);
try {
// 登录成功后立即保存 token 和用户信息,再统一进入应用态加载流程。
const data = await api<LoginResponse>("/auth/login", {
method: "POST",
body: {
@@ -114,6 +123,7 @@ async function logout(): Promise<void> {
try {
await api("/auth/logout", { method: "POST", body: {} });
} catch {
// 服务端登出失败不阻塞本地清理,避免用户卡在失效会话上。
}
setToken("");
state.user = null;
@@ -124,6 +134,7 @@ async function logout(): Promise<void> {
}
async function showApp(): Promise<void> {
// 路由列表是成员和管理员都可见的基础视图,因此先加载它。
setView("appView");
setSubtitle("adminSubtitle");
renderSessionUser();
@@ -131,12 +142,14 @@ async function showApp(): Promise<void> {
applyRoleVisibility();
await loadRoutes();
if (isMember()) {
// 成员权限可以查看运行态、服务、指标和插件,但不能管理用户与审计。
await loadStatus();
await loadServices();
await loadMetrics();
await loadPlugins();
}
if (isAdmin()) {
// 管理员专属数据放在最后加载,减少普通成员的无权限请求。
await loadUsers();
await loadAudit();
}
@@ -145,6 +158,7 @@ async function showApp(): Promise<void> {
function applyRoleVisibility(): void {
const member = isMember();
const admin = isAdmin();
// 角色控制只隐藏入口;服务端仍会按 token 做权限校验。
el("statusGrid").classList.toggle("hidden", !member);
el("newRouteBtn").classList.toggle("hidden", !member);
toggleTab("services", member);
@@ -176,6 +190,7 @@ function setView(name: string): void {
}
function rerenderCurrentView(): void {
// 切换语言后复用当前内存状态重绘静态文案,再刷新会随语言展示的远端数据。
renderSessionUser();
renderRoutes();
renderServices();

View File

@@ -1,3 +1,5 @@
// cmd/gateway/admin_frontend/src/session.ts 渲染会话用户状态,并提供管理端界面使用的角色判断。
import { el } from "./dom.js";
import { t } from "./i18n.js";
import { state } from "./state.js";

View File

@@ -1,3 +1,5 @@
// cmd/gateway/admin_frontend/src/state.ts 保存各视图模块共享的可变客户端状态。
import type { PluginArtifact, PluginBuild, PluginInstrumentation, PluginServiceStatus, PluginView, RouteRecord, ServiceRecord, User } from "./types.js";
export const tokenStorageKey = "mcGatewayAdminToken";

View File

@@ -1,3 +1,5 @@
// cmd/gateway/admin_frontend/src/types.ts 声明 Admin API 返回并被各视图消费的 TypeScript 数据结构。
export type Role = "admin" | "member" | "guest";
export interface User {

View File

@@ -1,3 +1,5 @@
// cmd/gateway/admin_frontend/src/views/audit.ts 渲染管理员用于复核运行态变更的审计日志。
import { api } from "../api.js";
import { showAlert } from "../alerts.js";
import { badge, el, escapeHTML } from "../dom.js";

View File

@@ -1,3 +1,5 @@
// cmd/gateway/admin_frontend/src/views/metrics.ts 渲染管理端成员可见的网关指标计数器。
import { api } from "../api.js";
import { showAlert } from "../alerts.js";
import { el, escapeHTML, stat } from "../dom.js";

View File

@@ -1,3 +1,5 @@
// cmd/gateway/admin_frontend/src/views/plugins.ts 渲染插件清单、插件详情、配置/密钥/治理动作和运维工具。
import { api } from "../api.js";
import { showAlert } from "../alerts.js";
import { badge, el, escapeAttr, escapeHTML, getFormInput } from "../dom.js";
@@ -50,6 +52,8 @@ interface OperationsResponse {
export async function loadPlugins(): Promise<void> {
try {
// 插件页首屏依赖插件记录、制品、构建、插件服务模式和观测数据;
// 并行请求可以减少进入页面时的等待时间。
const [data, artifacts, builds, service, instrumentation] = await Promise.all([
api<PluginsResponse>("/plugins"),
api<ArtifactsResponse>("/plugin-artifacts"),
@@ -64,6 +68,7 @@ export async function loadPlugins(): Promise<void> {
state.pluginInstrumentation = instrumentation.instrumentation || [];
const firstPlugin = state.plugins[0];
if (!state.selectedPluginID && firstPlugin) {
// 初次进入时默认选中第一个已纳管插件;未纳管制品会在列表中单独展示。
state.selectedPluginID = firstPlugin.id;
}
renderPlugins();
@@ -79,6 +84,7 @@ export async function loadPlugins(): Promise<void> {
export function renderPlugins(): void {
const managed = new Set(state.plugins.map((plugin) => plugin.id));
// 未纳管制品还没有 plugins 表记录,但仍要展示,方便管理员创建期望状态。
const unmanagedArtifacts = state.pluginArtifacts.filter((artifact) => !managed.has(artifact.plugin_id));
renderPluginServicePanel();
el("pluginsBody").innerHTML = state.plugins.map((plugin) => `
@@ -136,6 +142,7 @@ export async function loadPluginDetail(pluginID: string): Promise<void> {
try {
const data = await api<PluginResponse>(`/plugins/${encodeURIComponent(pluginID)}`);
if (data.plugin) {
// 详情接口返回完整插件视图,用它回填列表中的摘要记录。
state.plugins = state.plugins.map((plugin) => plugin.id === data.plugin?.id ? data.plugin : plugin);
if (!state.plugins.some((plugin) => plugin.id === data.plugin?.id)) {
state.plugins.push(data.plugin);
@@ -152,11 +159,13 @@ export async function loadPluginDetail(pluginID: string): Promise<void> {
export function renderPluginDetail(plugin: PluginView | null = selectedPlugin()): void {
const detail = el("pluginDetail");
if (!plugin) {
// 没有选中纳管插件时展示制品库存和源码构建入口。
detail.innerHTML = uploadInventoryDetail();
bindInventoryEvents();
return;
}
const canWrite = isAdmin();
// 插件详情拆成多个小面板,避免配置、治理、构建和运维信息混成一个长表格。
detail.innerHTML = `
<div class="detail-header">
<div>
@@ -252,6 +261,7 @@ export function renderPluginDetail(plugin: PluginView | null = selectedPlugin())
}
export function bindPluginEvents(): void {
// 顶层插件页事件只绑定一次;详情区会在每次重绘后重新绑定动态按钮。
el<HTMLInputElement>("pluginUploadInput").addEventListener("change", uploadPluginPackage);
el<HTMLButtonElement>("refreshPluginsBtn").addEventListener("click", loadPlugins);
}
@@ -263,6 +273,7 @@ function renderPluginServicePanel(): void {
}
const service = state.pluginService?.service;
const canWrite = isAdmin();
// 插件服务模式决定插件在进程内运行还是进入未来的独立/沙箱运行模式。
container.innerHTML = `
<section class="panel">
<div class="detail-header compact">
@@ -305,6 +316,7 @@ 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") },
@@ -342,6 +354,7 @@ async function uploadPluginPackage(event: Event): Promise<void> {
const formData = new FormData();
formData.set("artifact", file);
try {
// 浏览器只负责上传文件manifest 校验、哈希和制品类型判断由后端完成。
await api("/plugin-artifacts", { method: "POST", formData });
input.value = "";
await loadPlugins();
@@ -352,6 +365,7 @@ async function uploadPluginPackage(event: Event): Promise<void> {
}
function bindPluginDetailEvents(plugin: PluginView): void {
// 详情区每次重绘都会替换 DOM因此按钮事件必须在重绘后重新绑定。
document.getElementById("pluginDryRunBtn")?.addEventListener("click", () => dryRunConfig(plugin));
document.getElementById("pluginSaveConfigBtn")?.addEventListener("click", () => saveConfig(plugin));
const secretForm = document.getElementById("pluginSecretForm");
@@ -389,6 +403,7 @@ function bindPluginDetailEvents(plugin: PluginView): void {
async function dryRunConfig(plugin: PluginView): Promise<void> {
try {
// dry-run 不保存配置只返回脱敏后的校验结果、diff 和是否需要重启。
const data = await api<DryRunResponse>(`/plugins/${encodeURIComponent(plugin.id)}/config/dry-run`, {
method: "POST",
body: {
@@ -405,6 +420,7 @@ async function dryRunConfig(plugin: PluginView): Promise<void> {
async function saveConfig(plugin: PluginView): Promise<void> {
try {
// 配置保存写入期望状态;后端会根据当前制品和运行态判断是否可热加载。
await api(`/plugins/${encodeURIComponent(plugin.id)}/config`, {
method: "PUT",
body: {
@@ -425,6 +441,7 @@ async function saveSecret(event: SubmitEvent, plugin: PluginView): Promise<void>
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
try {
// 密钥值不回显,保存后通过重新加载详情刷新版本号和 reload 标记。
await api(`/plugins/${encodeURIComponent(plugin.id)}/secrets`, {
method: "POST",
body: {
@@ -448,6 +465,7 @@ async function runPluginAction(pluginID: string, action: string): Promise<void>
return;
}
try {
// enable/disable/load/delete 等动作都走统一动作接口,后端负责审计和操作日志。
await api(`/plugins/${encodeURIComponent(pluginID)}/${action}`, { method: "POST", body: {} });
if (action === "delete") {
state.selectedPluginID = "";
@@ -463,6 +481,7 @@ async function runPluginAction(pluginID: string, action: string): Promise<void>
async function createDesiredFromArtifact(artifact: PluginArtifact): Promise<void> {
try {
// 从未纳管制品创建 disabled 期望状态,管理员随后可以编辑配置再启用。
await api(`/plugins/${encodeURIComponent(artifact.plugin_id)}`, {
method: "PUT",
body: {
@@ -483,6 +502,7 @@ async function createDesiredFromArtifact(artifact: PluginArtifact): Promise<void
async function rollbackArtifact(pluginID: string, artifactID: string): Promise<void> {
try {
// 制品回滚只改期望制品;后端仍会执行治理检查和配置 dry-run。
await api(`/plugins/${encodeURIComponent(pluginID)}/rollback/artifact`, {
method: "POST",
body: { artifact_id: artifactID },
@@ -499,6 +519,7 @@ async function runBuildAction(pluginID: string, buildID: number, action: string)
return;
}
try {
// 构建动作可能耗时,当前界面以刷新详情的方式展示最新构建状态。
await api(`/plugin-builds/${buildID}/${encodeURIComponent(action)}`, { method: "POST", body: {} });
await loadPluginDetail(pluginID);
showAlert("");
@@ -512,6 +533,7 @@ async function rollbackSnapshot(pluginID: string, snapshotID: number, fullDesire
return;
}
try {
// 配置快照回滚可只恢复配置,也可连同 artifact/desired state/priority 一起恢复。
await api(`/plugins/${encodeURIComponent(pluginID)}/rollback/config`, {
method: "POST",
body: { snapshot_id: snapshotID, full_desired: fullDesired },
@@ -528,6 +550,7 @@ async function showSnapshotDiff(pluginID: string, snapshotID: number): Promise<v
return;
}
try {
// diff 已由后端脱敏,前端只负责展示结果给管理员确认。
const data = await api<SnapshotDiffResponse>(`/plugins/${encodeURIComponent(pluginID)}/config/snapshots/${snapshotID}/diff`);
el("pluginDryRunResult").textContent = formatJSON(data.diff || {});
showAlert("");
@@ -538,6 +561,7 @@ async function showSnapshotDiff(pluginID: string, snapshotID: number): Promise<v
async function createGovernanceReview(plugin: PluginView): Promise<void> {
try {
// 评审记录绑定当前 desired artifact 和配置哈希,用于后续启用或回滚门禁。
await api(`/plugins/${encodeURIComponent(plugin.id)}/governance/review`, {
method: "POST",
body: { artifact_id: plugin.desired_artifact_id, profile: "prod", decision: "approved" },
@@ -555,6 +579,7 @@ async function createGovernanceOverride(plugin: PluginView): Promise<void> {
return;
}
try {
// override 是带 TTL 的临时治理豁免,必须记录人工原因。
await api(`/plugins/${encodeURIComponent(plugin.id)}/governance/override`, {
method: "POST",
body: { artifact_id: plugin.desired_artifact_id, profile: "prod", action: "enable", reason, ttl_seconds: 3600 },
@@ -568,6 +593,7 @@ async function createGovernanceOverride(plugin: PluginView): Promise<void> {
async function runGovernancePreflight(plugin: PluginView): Promise<void> {
try {
// preflight 由插件或宿主返回检查项,结果会持久化到治理面板。
const data = await api<Record<string, unknown>>(`/plugins/${encodeURIComponent(plugin.id)}/governance/preflight`, {
method: "POST",
body: { artifact_id: plugin.desired_artifact_id, config_json: configEditorValue() },
@@ -582,6 +608,7 @@ async function runGovernancePreflight(plugin: PluginView): Promise<void> {
async function runGovernanceSelfTest(plugin: PluginView): Promise<void> {
try {
// self-test 用于验证制品自身能力,不直接修改 desired state。
const data = await api<Record<string, unknown>>(`/plugins/${encodeURIComponent(plugin.id)}/governance/self-test`, {
method: "POST",
body: { artifact_id: plugin.desired_artifact_id },
@@ -600,6 +627,7 @@ async function recordGovernanceBenchmark(plugin: PluginView): Promise<void> {
return;
}
try {
// 手动录入基准差异用于治理门禁判断,避免高风险性能回退直接启用。
await api(`/plugins/${encodeURIComponent(plugin.id)}/governance/benchmark`, {
method: "POST",
body: {
@@ -630,6 +658,7 @@ async function createArtifactRevokeAdvisory(plugin: PluginView): Promise<void> {
return;
}
try {
// 撤销公告会让命中的制品进入隔离/阻断路径,详情刷新后展示最新治理状态。
await api("/plugin-advisories", {
method: "POST",
body: {
@@ -649,6 +678,7 @@ async function createArtifactRevokeAdvisory(plugin: PluginView): Promise<void> {
async function loadPluginOperations(plugin: PluginView): Promise<void> {
try {
// 运维快照包含事件、日志、trace、任务、外部依赖和 GC 候选项,按需刷新即可。
const data = await api<OperationsResponse>(`/plugins/${encodeURIComponent(plugin.id)}/operations`);
el("pluginOperationsOutput").textContent = formatJSON(data.operations || {});
showAlert("");
@@ -659,6 +689,7 @@ async function loadPluginOperations(plugin: PluginView): Promise<void> {
async function dryRunOperationsGC(plugin: PluginView): Promise<void> {
try {
// GC dry-run 不删除文件,只展示哪些运行态数据会被保护或清理。
const data = await api<OperationsResponse>(`/plugins/${encodeURIComponent(plugin.id)}/operations/gc`);
el("pluginOperationsOutput").textContent = formatJSON(data);
showAlert("");
@@ -669,6 +700,7 @@ async function dryRunOperationsGC(plugin: PluginView): Promise<void> {
async function loadDiagnosticPackage(plugin: PluginView): Promise<void> {
try {
// 诊断包由后端生成并脱敏,前端以 JSON 文本形式展示给管理员。
const data = await api<OperationsResponse>(`/plugins/${encodeURIComponent(plugin.id)}/operations/diagnostic`);
el("pluginOperationsOutput").textContent = formatJSON(data);
showAlert("");
@@ -678,6 +710,7 @@ async function loadDiagnosticPackage(plugin: PluginView): Promise<void> {
}
function uploadInventoryDetail(): string {
// 库存视图聚合未纳管制品和构建记录,支撑上传、构建、纳管的完整流程。
const artifact = selectedArtifact();
if (!artifact) {
return `
@@ -732,6 +765,7 @@ function uploadInventoryDetail(): string {
}
function bindInventoryEvents(): void {
// 库存视图也是动态渲染,制品详情和纳管表单事件需要在渲染后绑定。
const artifact = selectedArtifact();
const form = document.getElementById("artifactDesiredForm");
if (artifact && form instanceof HTMLFormElement) {

View File

@@ -1,3 +1,5 @@
// cmd/gateway/admin_frontend/src/views/routes.ts 渲染路由列表,并通过 Admin API 保存主机到上游的变更。
import { api } from "../api.js";
import { showAlert } from "../alerts.js";
import { badge, el, escapeAttr, escapeHTML, getFormInput } from "../dom.js";

View File

@@ -1,3 +1,5 @@
// cmd/gateway/admin_frontend/src/views/services.ts 渲染监听服务设置,并持久化启停、端口和选项更新。
import { api } from "../api.js";
import { showAlert } from "../alerts.js";
import { el, escapeAttr, escapeHTML, getFormInput } from "../dom.js";

View File

@@ -1,3 +1,5 @@
// cmd/gateway/admin_frontend/src/views/status.ts 渲染管理面板上的网关健康摘要。
import { api } from "../api.js";
import { showAlert } from "../alerts.js";
import { el, stat } from "../dom.js";

View File

@@ -1,3 +1,5 @@
// cmd/gateway/admin_frontend/src/views/users.ts 渲染管理用户列表,并保存账号、角色、密码和禁用状态变更。
import { api } from "../api.js";
import { showAlert } from "../alerts.js";
import { badge, el, escapeAttr, escapeHTML, getFormInput, getFormSelect } from "../dom.js";

View File

@@ -1,3 +1,5 @@
// cmd/gateway/admin_metric_handlers.go 返回管理面板状态卡片使用的轻量运行时计数器。
package main
import (

View File

@@ -1,3 +1,5 @@
// cmd/gateway/admin_plugin_handlers.go 承载插件制品、期望状态、配置、密钥、运维操作和治理检查相关的 Admin API。
package main
import (

View File

@@ -1,3 +1,5 @@
// cmd/gateway/admin_route_handlers.go 提供修改路由记录的 HTTP 接口,并在提交后立刻刷新内存路由快照。
package main
import (

View File

@@ -1,3 +1,5 @@
// cmd/gateway/admin_routes.go 让内存中的主机到上游映射快照与 SQLite 路由记录保持同步。
package main
import (
@@ -8,10 +10,14 @@ import (
)
var (
routeSnapshot = adminroute.NewSnapshot()
// routeSnapshot 是连接热路径读取的不可变快照;写路径通过 Store 整体替换它。
routeSnapshot = adminroute.NewSnapshot()
// routeWriteLock 串行化路由写入和快照刷新,避免并发写导致后写库、先发布的顺序错乱。
routeWriteLock sync.Mutex
)
// refreshRouteSnapshot 从 SQLite 读取启用路由并发布到热路径。数据库尚未初始化时
// 发布空快照,方便测试和早期启动路径调用。
func refreshRouteSnapshot(ctx context.Context) error {
if adminDB == nil {
publishRouteSnapshot(map[string]string{})
@@ -26,10 +32,12 @@ func refreshRouteSnapshot(ctx context.Context) error {
return nil
}
// publishRouteSnapshot 原子替换当前路由快照;调用方应传入新 map避免发布后继续修改。
func publishRouteSnapshot(routes map[string]string) {
routeSnapshot.Store(routes)
}
// lookupRoute 是连接热路径使用的只读查找函数,不访问 SQLite。
func lookupRoute(host string) (string, bool) {
return routeSnapshot.Lookup(host)
}
@@ -42,6 +50,7 @@ func upsertRoute(ctx context.Context, actor, host, upstream string, enabled bool
routeWriteLock.Lock()
defer routeWriteLock.Unlock()
// 路由写入成功后必须立即刷新内存快照,否则管理端保存的配置不会影响新连接。
if err := adminroute.NewRepository(adminDB).Upsert(ctx, actor, host, upstream, enabled, note); err != nil {
return err
}
@@ -52,6 +61,7 @@ func deleteRoute(ctx context.Context, actor, host string) error {
routeWriteLock.Lock()
defer routeWriteLock.Unlock()
// 删除也走同一把锁,确保快照刷新顺序与数据库提交顺序一致。
if err := adminroute.NewRepository(adminDB).Delete(ctx, host); err != nil {
return err
}

View File

@@ -1,3 +1,5 @@
// cmd/gateway/admin_runtime.go 打开 SQLite 运行态数据库、写入默认数据,并为在线流量发布首个路由快照。
package main
import (
@@ -36,6 +38,7 @@ const (
)
var (
// adminStartup 是启动时解析出的管理端配置;后续 HTTP handler 和静态资源注入都会读取它。
adminStartup = adminconfig.Config{
DBPath: defaultAdminDBPath,
TCPAdminPort: defaultTCPPort,
@@ -50,6 +53,8 @@ var (
processStartAt = time.Now()
)
// initializeGatewayRuntime 按固定顺序准备运行态:解析配置、打开数据库、迁移 schema、
// 写入默认服务、应用服务配置、创建初始管理员、发布路由快照、最后启动插件管理器。
func initializeGatewayRuntime() error {
startup, err := parseStartupConfig(os.Getenv)
if err != nil {
@@ -71,6 +76,7 @@ func initializeGatewayRuntime() error {
if err := admindb.Migrate(db); err != nil {
return err
}
// 默认服务必须先存在applyServiceConfig 才能把 SQLite 中的运行态端口写回 config。
if err := ensureDefaultServices(context.Background(), db, startup.TCPAdminPort); err != nil {
return err
}
@@ -84,6 +90,7 @@ func initializeGatewayRuntime() error {
return err
}
// 插件制品放在数据库同级目录下,便于容器挂载一个 data volume 即可保留全部运行态。
pluginsManager = pluginmanager.New(pluginmanager.Options{
DB: db,
ArtifactRoot: filepath.Join(filepath.Dir(startup.DBPath), "plugins", "artifacts"),
@@ -93,6 +100,7 @@ func initializeGatewayRuntime() error {
return pluginsManager.Reconcile(context.Background())
}
// closeGatewayRuntime 只关闭当前进程持有的数据库连接SQLite 文件和插件制品都保留在数据目录中。
func closeGatewayRuntime() {
if adminDB != nil {
_ = adminDB.Close()

View File

@@ -1,3 +1,5 @@
// cmd/gateway/admin_service_handlers.go 提供监听服务配置接口,用于维护端口、启停状态和是否需要重启。
package main
import (

View File

@@ -1,3 +1,5 @@
// cmd/gateway/admin_services.go 从 SQLite 加载监听服务配置,并暴露规范化后的运行时服务选项。
package main
import (

View File

@@ -1,3 +1,5 @@
// cmd/gateway/admin_sessions.go 提供 Admin API 认证中间件使用的内存会话管理器。
package main
import (

View File

@@ -1,3 +1,5 @@
// cmd/gateway/admin_static.go 嵌入构建后的管理前端,并通过网关 HTTP 处理器对外提供。
package main
import (

View File

@@ -1,3 +1,5 @@
/* cmd/gateway/admin_static/app.css 定义嵌入式管理端仪表盘、表格、表单、对话框和响应式布局样式。 */
:root {
color-scheme: light;
--bg: #f6f7f4;

View File

@@ -1,3 +1,5 @@
<!-- cmd/gateway/admin_static/index.html 提供嵌入式管理端 HTML 外壳,包含对话框、标签页和运行时 API 前缀注入。 -->
<!doctype html>
<html lang="en">
<head>

View File

@@ -1,3 +1,5 @@
// cmd/gateway/admin_user_handlers.go 提供管理员维护管理账号的接口,包括创建、更新、禁用和列表查询。
package main
import (

View File

@@ -1,3 +1,5 @@
// cmd/gateway/admin_users.go 初始化管理用户仓库,并在没有账号时创建首次初始化用户。
package main
import (

View File

@@ -1,3 +1,5 @@
// cmd/gateway/config.go 加载静态网关配置,并与管理数据库提供的运行态状态组合使用。
package main
import (

View File

@@ -1,3 +1,5 @@
// cmd/gateway/config_test.go 包含用于约束 config 行为的测试。
package main
import (

View File

@@ -1,3 +1,5 @@
// cmd/gateway/err.go 集中放置网关请求路径使用的少量哨兵错误。
package main
import "errors"

View File

@@ -1,3 +1,5 @@
// cmd/gateway/handle_request_test.go 包含用于约束 handle request 行为的测试。
package main
import (

View File

@@ -1,3 +1,5 @@
// cmd/gateway/haproxy.go 为需要 HAProxy PROXY 头的上游 TCP 连接先写入代理头,再回放 Minecraft 流量。
package main
import (
@@ -45,7 +47,7 @@ func haProxyUpstream(source net.Conn, host string) net.Conn {
SourceAddr: sourceAddr,
DestinationAddr: target,
}
// After the connection was created write the proxy headers first
// 连接建立后先写入 PROXY 头,再转发 Minecraft 首包。
_, err = header.WriteTo(conn)
if err != nil {
log.Err(err).Msg("failed to write proxy header")

View File

@@ -1,3 +1,5 @@
// cmd/gateway/haproxy_test.go 包含用于约束 haproxy 行为的测试。
package main
import (

View File

@@ -1,3 +1,5 @@
// cmd/gateway/kcp.go 启动可选的 KCP 监听器,并把接收到的会话转入统一网关请求处理流程。
package main
import (
@@ -14,6 +16,7 @@ func runKcp(wg *sync.WaitGroup) {
defer wg.Done()
}
// KCP 监听使用运行态服务配置中的分片参数,和上游拨号保持一致。
listener, err := kcp.ListenWithOptions(fmt.Sprintf(":%d", config.Kcp.Port), nil, config.Kcp.DataShards, config.Kcp.ParityShards)
if err != nil {
log.Fatal().Err(err).
@@ -37,11 +40,13 @@ func runKcp(wg *sync.WaitGroup) {
tuneKcpConn(conn)
// KCP session 实现 net.Conn可以直接进入统一网关请求流程。
go handleRequest(conn)
}
}
func upstreamKcp(host string) net.Conn {
// KCP 上游使用与入口相同的 data/parity shards确保两端编码参数匹配。
conn, err := kcp.DialWithOptions(host, nil, config.Kcp.DataShards, config.Kcp.ParityShards)
if err != nil {
gatewayMetrics.UpstreamDialError()
@@ -55,6 +60,7 @@ func upstreamKcp(host string) net.Conn {
}
func tuneKcpConn(conn *kcp.UDPSession) {
// 这里偏向低延迟交互stream mode 模拟 TCP 字节流,禁用写延迟并打开快速 ACK。
conn.SetStreamMode(true)
conn.SetWriteDelay(false)
conn.SetNoDelay(1, 10, 2, 1)

View File

@@ -1,3 +1,5 @@
// cmd/gateway/log.go 配置网关日志、日志文件、日志级别和日志轮转钩子。
package main
import (

View File

@@ -1,3 +1,5 @@
// cmd/gateway/log_notunix.go 在没有 Unix 信号的平台上提供空的日志轮转信号钩子。
// pid_unix.go
//go:build !unix && !plan9
@@ -8,8 +10,8 @@ import (
)
func handleLogRotate() {
// No-op for non-unix platforms
// Log rotation is not supported on this platform
// This function can be left empty or removed if not needed
// 非 Unix 平台不执行日志轮转信号处理。
// 该平台不支持通过信号触发日志轮转。
// 保留空实现是为了让跨平台调用点保持一致。
log.Info().Msg("Log rotation is not supported on this platform")
}

View File

@@ -1,3 +1,5 @@
// cmd/gateway/log_pid_test.go 包含用于约束 log pid 行为的测试。
package main
import (

View File

@@ -1,3 +1,5 @@
// cmd/gateway/log_unix.go 注册 Unix 信号处理,让进程无需完整重启即可重新打开日志文件。
// pid_unix.go
//go:build unix || plan9

View File

@@ -1,3 +1,5 @@
// cmd/gateway/main.go 负责网关进程启动、监听器选择、Minecraft 握手路由、插件钩子分发以及上游转发交接。
package main
import (
@@ -17,6 +19,8 @@ import (
)
func main() {
// 插件子命令复用网关二进制。这里先于运行态配置加载处理它们,
// 这样本地构建和清单命令不需要一份可用的网关部署配置。
if handled, code := runPluginCLI(os.Args[1:]); handled {
os.Exit(code)
}
@@ -39,8 +43,13 @@ func main() {
}
func startEnabledServices() {
// TCP 和 Admin HTTP 始终通过共享监听器启动。共享监听器按每条连接
// 的首包判断它是 HTTP 还是 Minecraft 协议数据,因此不需要额外维护
// 一个手动模式开关。
startService(runTcpWebPortReuse)
// 可选传输最终仍进入 handleRequest这让插件过滤、路由解析和上游拨号
// 在 TCP、KCP、QUIC 和 WebSocket 入口之间保持一致。
if config.Kcp.Enable {
startService(runKcp)
}
@@ -61,6 +70,8 @@ func handleRequest(conn net.Conn) {
gatewayMetrics.ConnectionStarted()
defer gatewayMetrics.ConnectionFinished()
// 插件或协议解析器的 panic 不能杀掉监听协程;当前连接会被放弃,
// 进程继续服务其他客户端。
defer func() {
rec := recover()
if rec == nil {
@@ -91,6 +102,8 @@ func handleRequest(conn net.Conn) {
}
func mapToHost(conn net.Conn) net.Conn {
// 连接过滤器在读取 Minecraft 握手前执行,因此可以按来源地址或传输类型
// 拒绝连接,同时不消耗客户端发送的协议字节。
if pluginsManager != nil {
transport, _, _ := connectionIngress(conn)
filter, err := pluginsManager.FilterConnection(context.Background(), api.ConnectionFilterRequest{
@@ -124,6 +137,8 @@ func mapToHost(conn net.Conn) net.Conn {
return nil
}
// 第一次读取包含 Minecraft 握手数据。所有过滤器和路由决策完成后,
// 这段数据必须原样或按插件改写后回放给选中的上游。
initialData := append([]byte(nil), buf[:n]...)
handshake := protocol.ParseHandshake(initialData)
if handshake.ServerHost == "" {
@@ -133,6 +148,8 @@ func mapToHost(conn net.Conn) net.Conn {
return nil
}
// 握手过滤器可以改写目标主机名。发生改写时要立刻重建首包,
// 确保上游看到的是改写后的 Minecraft 主机名,而不是客户端原始值。
if pluginsManager != nil {
filter, err := pluginsManager.FilterHandshake(context.Background(), api.HandshakeFilterRequest{
SourceAddr: conn.RemoteAddr().String(),
@@ -155,6 +172,8 @@ func mapToHost(conn net.Conn) net.Conn {
}
}
// 状态查询使用 NextState=1并且可以由插件直接完整响应。
// 如果这里已经处理,就不会再为该查询打开上游连接。
if handshake.NextState == 1 {
if handled := handleStatusPing(conn, handshake); handled {
return nil
@@ -226,6 +245,8 @@ func mapToHost(conn net.Conn) net.Conn {
if client == nil {
target := upstreamtarget.Parse(host)
// 路由值可以通过前缀选择非 TCP 传输;普通地址仍按 TCP 处理,
// 以保持旧配置的行为不变。
switch target.Protocol {
case upstreamtarget.ProtocolQUIC:
client = upstreamQuic(target.Address)
@@ -241,6 +262,8 @@ func mapToHost(conn net.Conn) net.Conn {
return nil
}
// 只有在上游路径确定后才回放握手数据。这样插件在任何上游字节发出前,
// 都还有机会阻断、代理或改写连接。
if err := writeAll(client, initialData); err != nil {
log.Err(err).
Str("client", conn.RemoteAddr().String()).
@@ -256,6 +279,8 @@ func mapToHost(conn net.Conn) net.Conn {
func resolveGatewayRoute(conn net.Conn, handshake protocol.Handshake) pluginmanager.RouteResolveResult {
upstream, hit := lookupRoute(handshake.ServerHost)
// SQLite 快照始终作为本地兜底。插件会同时拿到兜底决策和刷新回调,
// 因此可以选择性覆盖路由,而不必在插件里复制一套路由仓库逻辑。
req := api.RouteResolveRequest{
Host: handshake.ServerHost,
RawServerHost: handshake.RawServerHost,
@@ -283,6 +308,7 @@ func resolveGatewayRoute(conn net.Conn, handshake protocol.Handshake) pluginmana
action := api.RouteDecisionFallback
source := "sqlite_fallback"
if upstream == "" {
// 没有命中兜底路由时统一表示为拒绝决策,便于热路径记录一致的失败形态。
action = api.RouteDecisionReject
source = "fallback_miss"
}
@@ -296,6 +322,8 @@ func handleStatusPing(conn net.Conn, handshake protocol.Handshake) bool {
if pluginsManager == nil {
return false
}
// Minecraft 状态响应是带长度前缀的 JSON 数据包。插件只提供高层字段,
// Minecraft 协议封包由 protocol.StatusResponsePacket 统一完成。
result, err := pluginsManager.StatusPing(context.Background(), api.StatusPingRequest{
Host: handshake.ServerHost,
RawServerHost: handshake.RawServerHost,
@@ -339,6 +367,8 @@ func handleStatusPing(conn net.Conn, handshake protocol.Handshake) bool {
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)
// InitialData 使用副本,避免上游插件在其他处理器或日志路径仍引用回放缓冲区时
// 意外修改调用方持有的数据。
req := api.UpstreamConnectRequest{
Source: conn,
Host: handshake.ServerHost,
@@ -367,6 +397,8 @@ func newUpstreamConnectRequest(conn net.Conn, upstream string, handshake protoco
func connectionIngress(conn net.Conn) (transport string, serviceName string, listenerPort int) {
transport = "tcp"
serviceName = serviceNameTCPAdmin
// 具体连接包装类型记录了客户端来自哪个监听器。该元数据会传给插件,
// 并出现在运维诊断中,同时不需要改变 net.Conn 接口。
switch conn.(type) {
case *webSocketConn:
transport = "websocket"

View File

@@ -1,3 +1,5 @@
// cmd/gateway/main_test.go 包含用于约束 gateway 行为的测试。
package main
import (

View File

@@ -1,3 +1,5 @@
// cmd/gateway/pid.go 维护 pid 文件的写入和清理,供进程管理器按文件追踪网关进程。
package main
import (

View File

@@ -1,3 +1,5 @@
// cmd/gateway/pid_unix.go 实现 Unix 平台的 pid 文件占用检查,避免覆盖仍在运行的进程记录。
// pid_unix.go
//go:build unix || plan9

View File

@@ -1,5 +1,7 @@
//go:build unix || plan9
// cmd/gateway/pid_unix_test.go 包含用于约束 pid unix 行为的测试。
package main
import "testing"

View File

@@ -1,3 +1,5 @@
// cmd/gateway/pid_windows.go 在 Windows 上提供可移植的 pid 文件占用检查替代实现。
// pid_windows.go
//go:build windows

View File

@@ -1,3 +1,5 @@
// cmd/gateway/plugin.go 把网关运行时接入 pluginmanager负责钩子分发和插件生命周期加载。
package main
import (
@@ -96,12 +98,12 @@ func loadPlugins() {
}
}
// HandleConn implements api.Gateway.
// HandleConn 实现 api.Gateway,用于让插件把连接交回网关主流程。
func (g *Gateway) HandleConn(conn net.Conn) {
go handleRequest(conn)
}
// Hook implements api.Gateway.
// Hook 实现 api.Gateway,用于注册旧版内存钩子处理器。
func (g *Gateway) Hook(hook string, handler any) error {
pluginLock.Lock()
defer pluginLock.Unlock()
@@ -110,7 +112,7 @@ func (g *Gateway) Hook(hook string, handler any) error {
return nil
}
// ExitWaitGroup implements api.Gateway.
// ExitWaitGroup 实现 api.Gateway,用于把插件后台任务纳入进程退出等待。
func (g *Gateway) ExitWaitGroup() *sync.WaitGroup {
return &exitWaitGroup
}
@@ -152,7 +154,7 @@ func (g *Gateway) RegisterBackgroundTask(task api.BackgroundTask) error {
return nil
}
// TestOp implements api.Gateway.
// TestOp 实现 api.Gateway,保留给测试或调试插件能力探测。
func (g *Gateway) TestOp() {
panic("unimplemented")
}

View File

@@ -1,3 +1,5 @@
// cmd/gateway/plugin_cli.go 分发插件相关子命令,包括本地脚手架、构建、清单和远程管理操作。
package main
import (
@@ -15,17 +17,184 @@ import (
)
func runPluginCLI(args []string) (bool, int) {
if len(args) < 2 || args[0] != "plugin" {
if len(args) < 1 || args[0] != "plugin" {
return false, 0
}
if len(args) < 3 {
fmt.Fprintln(os.Stderr, "usage: gateway plugin inspect|validate|compat|source-validate <artifact.mcgp> | source-build <source.mcgp> [out.mcgp]")
if len(args) < 2 {
printPluginCLIUsage()
return true, 2
}
command, packagePath := args[1], args[2]
command := args[1]
switch command {
case "init":
if err := runPluginInitCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "features":
if err := runPluginFeaturesCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "manifest":
if err := runPluginManifestCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "preflight":
if err := runPluginPreflightCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "self-test":
if err := runPluginSelfTestCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "benchmark":
if err := runPluginBenchmarkCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "status":
if err := runPluginRemoteStatusCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "upload":
if err := runPluginRemoteUploadCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "enable":
if err := runPluginRemoteDesiredCLI(args[2:], pluginmanager.DesiredEnabled); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "disable":
if err := runPluginRemoteActionCLI(args[2:], "disable"); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "delete":
if err := runPluginRemoteActionCLI(args[2:], "delete"); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "rollback":
if err := runPluginRemoteRollbackCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "config":
if err := runPluginRemoteConfigCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "secret":
if err := runPluginRemoteSecretCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "logs", "events", "metrics":
if err := runPluginRemoteOperationsSectionCLI(command, args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "diagnose":
if err := runPluginRemoteDiagnoseCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "task":
if err := runPluginRemoteTaskCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "data", "files":
if err := runPluginRemoteResourceCLI(command, args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "gc":
if err := runPluginRemoteGCCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "review":
if err := runPluginRemoteReviewCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "advisory":
if err := runPluginRemoteAdvisoryCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "repo":
if err := runPluginRemoteRepoCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "sbom", "verify":
if err := runPluginRemoteSupplyChainCLI(command, args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "runtime":
if err := runPluginRuntimeCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "schema", "contract", "conformance", "export", "import", "diff", "drift", "dr-drill", "sign":
if err := runPluginReservedCLI(command, args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "build":
if err := runPluginBuildCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "test":
if err := runPluginTestCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "inspect":
if len(args) < 3 {
printPluginCLIUsage()
return true, 2
}
packagePath := args[2]
manifest, err := readPackageManifest(packagePath)
if err != nil {
fmt.Fprintln(os.Stderr, err)
@@ -39,18 +208,7 @@ func runPluginCLI(args []string) (bool, int) {
}
return true, 0
case "validate", "compat":
tmpRoot, err := os.MkdirTemp("", "mcgp-cli-*")
if err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
defer os.RemoveAll(tmpRoot)
store := pluginmanager.NewArtifactStore(tmpRoot)
artifact, err := store.ValidateAndStore(pluginmanager.ArtifactUpload{
SourcePath: packagePath,
FileName: filepath.Base(packagePath),
Actor: "cli",
})
artifact, err := runPluginValidatePathCLI(args[2:], "")
if err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
@@ -59,18 +217,7 @@ func runPluginCLI(args []string) (bool, int) {
artifact.PluginID, artifact.Version, artifact.SHA256, artifact.APIVersion, artifact.GoVersion, artifact.GOOS, artifact.GOARCH)
return true, 0
case "source-validate":
tmpRoot, err := os.MkdirTemp("", "mcgp-source-cli-*")
if err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
defer os.RemoveAll(tmpRoot)
store := pluginmanager.NewArtifactStore(tmpRoot)
source, err := store.ValidateAndStoreSource(pluginmanager.ArtifactUpload{
SourcePath: packagePath,
FileName: filepath.Base(packagePath),
Actor: "cli",
})
source, err := runPluginValidatePathCLI(args[2:], pluginmanager.ArtifactTypeSource)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
@@ -79,6 +226,11 @@ func runPluginCLI(args []string) (bool, int) {
source.PluginID, source.Version, source.SHA256, source.APIVersion, source.GoVersion, source.GOOS, source.GOARCH)
return true, 0
case "source-build":
if len(args) < 3 {
printPluginCLIUsage()
return true, 2
}
packagePath := args[2]
outPath := ""
if len(args) >= 4 {
outPath = args[3]
@@ -97,6 +249,10 @@ func runPluginCLI(args []string) (bool, int) {
}
}
func printPluginCLIUsage() {
fmt.Fprintln(os.Stderr, "usage: gateway plugin init|features|manifest|build|test|preflight|self-test|benchmark|status|upload|enable|disable|delete|rollback|config|secret|logs|events|metrics|diagnose|task|data|files|gc|review|advisory|repo|sbom|verify|runtime|inspect|validate|compat|source-validate|source-build ...")
}
func buildSourcePackageForCLI(packagePath, outPath string) (pluginmanager.BuildRecord, string, error) {
tmpRoot, err := os.MkdirTemp("", "mcgp-source-build-cli-*")
if err != nil {

View File

@@ -0,0 +1,348 @@
// cmd/gateway/plugin_cli_manifest.go 实现插件包清单的查看、校验、特性列表和格式化命令。
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/pelletier/go-toml/v2"
"github.com/tursom/mc-gateway/internal/pluginmanager"
"gopkg.in/yaml.v3"
)
const (
manifestFormatJSON = "json"
manifestFormatJSONC = "jsonc"
manifestFormatYAML = "yaml"
manifestFormatTOML = "toml"
)
var manifestSourceNames = []string{
"manifest.yaml",
"manifest.yml",
"manifest.toml",
"manifest.jsonc",
"manifest.json",
}
type pluginManifestSource struct {
Path string
Format string
Data []byte
Manifest pluginmanager.Manifest
Raw map[string]any
CanonicalJSON []byte
}
func readPluginDirManifest(dir, explicitManifestPath string) (pluginmanager.Manifest, map[string]any, error) {
source, err := readPluginManifestSource(dir, explicitManifestPath)
if err != nil {
return pluginmanager.Manifest{}, nil, err
}
return source.Manifest, source.Raw, nil
}
func readPluginManifestSource(target, explicitManifestPath string) (pluginManifestSource, error) {
manifestPath, err := resolveManifestSourcePath(target, explicitManifestPath)
if err != nil {
return pluginManifestSource{}, err
}
return readPluginManifestSourceFile(manifestPath)
}
func resolveManifestSourcePath(target, explicitManifestPath string) (string, error) {
if explicitManifestPath != "" {
return resolveExplicitManifestPath(target, explicitManifestPath)
}
info, err := os.Stat(target)
if err != nil {
return "", err
}
if !info.IsDir() {
if !isManifestSourceFile(target) {
return "", fmt.Errorf("manifest path must be one of %s, got %q", strings.Join(manifestSourceNames, ", "), target)
}
return target, nil
}
var found []string
for _, name := range manifestSourceNames {
candidate := filepath.Join(target, name)
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
found = append(found, candidate)
}
}
if len(found) == 0 {
return "", fmt.Errorf("plugin manifest is required: expected one of %s in %s", strings.Join(manifestSourceNames, ", "), target)
}
if len(found) > 1 {
sort.Strings(found)
return "", fmt.Errorf("multiple plugin manifests found: %s; pass --manifest to select one", strings.Join(found, ", "))
}
return found[0], nil
}
func resolveExplicitManifestPath(target, explicitManifestPath string) (string, error) {
candidates := []string{explicitManifestPath}
if info, err := os.Stat(target); err == nil && info.IsDir() && !filepath.IsAbs(explicitManifestPath) {
candidates = []string{filepath.Join(target, explicitManifestPath), explicitManifestPath}
}
for _, candidate := range candidates {
info, err := os.Stat(candidate)
if err != nil {
continue
}
if info.IsDir() {
return "", fmt.Errorf("manifest path %q is a directory", candidate)
}
if !isManifestSourceFile(candidate) {
return "", fmt.Errorf("manifest path must be one of %s, got %q", strings.Join(manifestSourceNames, ", "), candidate)
}
return candidate, nil
}
return "", fmt.Errorf("manifest path %q is not readable", explicitManifestPath)
}
func readPluginManifestSourceFile(manifestPath string) (pluginManifestSource, error) {
format, err := manifestFormatForPath(manifestPath)
if err != nil {
return pluginManifestSource{}, err
}
data, err := os.ReadFile(manifestPath)
if err != nil {
return pluginManifestSource{}, err
}
raw, err := decodeManifestSource(data, format)
if err != nil {
return pluginManifestSource{}, fmt.Errorf("invalid %s: %w", filepath.Base(manifestPath), err)
}
canonical, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return pluginManifestSource{}, err
}
canonical = append(canonical, '\n')
var manifest pluginmanager.Manifest
if err := json.Unmarshal(canonical, &manifest); err != nil {
return pluginManifestSource{}, fmt.Errorf("invalid %s object: %w", filepath.Base(manifestPath), err)
}
if manifest.ID == "" {
return pluginManifestSource{}, errors.New("manifest id is required")
}
return pluginManifestSource{
Path: manifestPath,
Format: format,
Data: data,
Manifest: manifest,
Raw: raw,
CanonicalJSON: canonical,
}, nil
}
func decodeManifestSource(data []byte, format string) (map[string]any, error) {
var raw any
switch format {
case manifestFormatJSON:
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
case manifestFormatJSONC:
stripped, err := stripJSONC(data)
if err != nil {
return nil, err
}
if err := json.Unmarshal(stripped, &raw); err != nil {
return nil, err
}
case manifestFormatYAML:
if err := yaml.Unmarshal(data, &raw); err != nil {
return nil, err
}
case manifestFormatTOML:
var table map[string]any
if err := toml.Unmarshal(data, &table); err != nil {
return nil, err
}
raw = table
default:
return nil, fmt.Errorf("unsupported manifest format %q", format)
}
normalized, ok := normalizeManifestValue(raw).(map[string]any)
if !ok {
return nil, errors.New("manifest root must be an object")
}
return normalized, nil
}
func normalizeManifestValue(value any) any {
switch v := value.(type) {
case map[string]any:
out := make(map[string]any, len(v))
for key, item := range v {
out[key] = normalizeManifestValue(item)
}
return out
case map[any]any:
out := make(map[string]any, len(v))
for key, item := range v {
out[fmt.Sprint(key)] = normalizeManifestValue(item)
}
return out
case []any:
out := make([]any, len(v))
for i, item := range v {
out[i] = normalizeManifestValue(item)
}
return out
default:
return value
}
}
func manifestFormatForPath(filePath string) (string, error) {
switch strings.ToLower(filepath.Base(filePath)) {
case "manifest.json":
return manifestFormatJSON, nil
case "manifest.jsonc":
return manifestFormatJSONC, nil
case "manifest.yaml", "manifest.yml":
return manifestFormatYAML, nil
case "manifest.toml":
return manifestFormatTOML, nil
default:
return "", fmt.Errorf("unsupported manifest file %q", filePath)
}
}
func isManifestSourceFile(filePath string) bool {
_, err := manifestFormatForPath(filePath)
return err == nil
}
func stripJSONC(data []byte) ([]byte, error) {
withoutComments, err := stripJSONCComments(data)
if err != nil {
return nil, err
}
return stripJSONCTrailingCommas(withoutComments), nil
}
func stripJSONCComments(data []byte) ([]byte, error) {
out := make([]byte, 0, len(data))
inString := false
escaped := false
for i := 0; i < len(data); i++ {
ch := data[i]
if inString {
out = append(out, ch)
if escaped {
escaped = false
continue
}
if ch == '\\' {
escaped = true
continue
}
if ch == '"' {
inString = false
}
continue
}
if ch == '"' {
inString = true
out = append(out, ch)
continue
}
if ch == '/' && i+1 < len(data) {
next := data[i+1]
if next == '/' {
out = append(out, ' ', ' ')
i += 2
for ; i < len(data); i++ {
if data[i] == '\n' || data[i] == '\r' {
out = append(out, data[i])
break
}
out = append(out, ' ')
}
continue
}
if next == '*' {
out = append(out, ' ', ' ')
i += 2
closed := false
for ; i < len(data); i++ {
if data[i] == '*' && i+1 < len(data) && data[i+1] == '/' {
out = append(out, ' ', ' ')
i++
closed = true
break
}
if data[i] == '\n' || data[i] == '\r' {
out = append(out, data[i])
} else {
out = append(out, ' ')
}
}
if !closed {
return nil, errors.New("unterminated block comment")
}
continue
}
}
out = append(out, ch)
}
if inString {
return nil, errors.New("unterminated string")
}
return out, nil
}
func stripJSONCTrailingCommas(data []byte) []byte {
var out bytes.Buffer
inString := false
escaped := false
for i := 0; i < len(data); i++ {
ch := data[i]
if inString {
out.WriteByte(ch)
if escaped {
escaped = false
continue
}
if ch == '\\' {
escaped = true
continue
}
if ch == '"' {
inString = false
}
continue
}
if ch == '"' {
inString = true
out.WriteByte(ch)
continue
}
if ch == ',' {
j := i + 1
for j < len(data) && isJSONWhitespace(data[j]) {
j++
}
if j < len(data) && (data[j] == '}' || data[j] == ']') {
continue
}
}
out.WriteByte(ch)
}
return out.Bytes()
}
func isJSONWhitespace(ch byte) bool {
return ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r'
}

View File

@@ -0,0 +1,979 @@
// cmd/gateway/plugin_cli_remote.go 实现通过 Admin API 驱动插件管理操作的命令行客户端。
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"github.com/tursom/mc-gateway/internal/pluginmanager"
)
type pluginRemoteOptions struct {
Gateway string
Token string
Target string
Extra []string
ArtifactID string
ConfigPath string
ConfigJSON string
Priority int
Source bool
SnapshotID int64
FullDesired bool
Profile string
Action string
Decision string
Notes string
Reason string
TTLSeconds int64
ConfirmToken string
DryRun bool
RepositoryType string
IndexPath string
Version string
TrustPolicy string
MetadataPath string
MetadataJSON string
BenchmarkProfile string
P95MS float64
P99MS float64
ErrorRate float64
ActiveProxyCapacity int64
BaselineDiff float64
Mode string
}
type pluginRemoteClient struct {
baseURL string
token string
client *http.Client
}
func runPluginRemoteStatusCLI(args []string) error {
opts, err := parsePluginRemoteOptions(args)
if err != nil {
return err
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
endpoint := "/plugins"
if opts.Target != "" {
endpoint = "/plugins/" + url.PathEscape(opts.Target)
}
return client.doToStdout(http.MethodGet, endpoint, nil)
}
func runPluginRemoteUploadCLI(args []string) error {
opts, err := parsePluginRemoteOptions(args)
if err != nil {
return err
}
if opts.Target == "" {
return errors.New("upload requires an artifact path")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
endpoint := "/plugin-artifacts"
if opts.Source {
endpoint = "/plugin-sources"
}
return client.uploadArtifact(endpoint, opts.Target)
}
func runPluginRemoteDesiredCLI(args []string, desiredState string) error {
opts, err := parsePluginRemoteOptions(args)
if err != nil {
return err
}
if opts.Target == "" {
return errors.New("enable requires a plugin id")
}
if opts.ArtifactID == "" {
return errors.New("enable requires --artifact")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
configJSON, err := remoteConfigJSON(opts)
if err != nil {
return err
}
body := map[string]any{
"artifact_id": opts.ArtifactID,
"desired_state": desiredState,
"config_json": configJSON,
"priority": opts.Priority,
"source": "cli",
"requested_mode": "desired",
}
return client.doToStdout(http.MethodPut, "/plugins/"+url.PathEscape(opts.Target), body)
}
func runPluginRemoteActionCLI(args []string, action string) error {
opts, err := parsePluginRemoteOptions(args)
if err != nil {
return err
}
if opts.Target == "" {
return fmt.Errorf("%s requires a plugin id", action)
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/"+action, nil)
}
func runPluginRemoteRollbackCLI(args []string) error {
opts, err := parsePluginRemoteOptions(args)
if err != nil {
return err
}
if opts.Target == "" {
return errors.New("rollback requires a plugin id")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
if opts.SnapshotID > 0 {
body := map[string]any{"snapshot_id": opts.SnapshotID, "full_desired": opts.FullDesired}
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/rollback/config", body)
}
if opts.ArtifactID == "" {
return errors.New("rollback requires --artifact or --snapshot")
}
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/rollback/artifact", map[string]any{"artifact_id": opts.ArtifactID})
}
func runPluginRemoteConfigCLI(args []string) error {
if len(args) == 0 {
return errors.New("usage: gateway plugin config validate <plugin-id> ...")
}
switch args[0] {
case "validate":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
if opts.Target == "" {
return errors.New("config validate requires a plugin id")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
configJSON, err := remoteConfigJSON(opts)
if err != nil {
return err
}
body := map[string]any{"artifact_id": opts.ArtifactID, "config_json": configJSON}
if opts.Priority != 0 {
body["priority"] = opts.Priority
}
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/config/dry-run", body)
default:
return fmt.Errorf("unknown config command %q", args[0])
}
}
func runPluginRemoteSecretCLI(args []string) error {
if len(args) == 0 {
return errors.New("usage: gateway plugin secret check <plugin-id> ...")
}
switch args[0] {
case "check":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
if opts.Target == "" {
return errors.New("secret check requires a plugin id")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
return client.doToStdout(http.MethodGet, "/plugins/"+url.PathEscape(opts.Target)+"/secrets", nil)
default:
return fmt.Errorf("unknown secret command %q", args[0])
}
}
func runPluginRemoteOperationsSectionCLI(command string, args []string) error {
opts, err := parsePluginRemoteOptions(args)
if err != nil {
return err
}
if opts.Target == "" {
return fmt.Errorf("%s requires a plugin id", command)
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
body, err := client.doJSON(http.MethodGet, "/plugins/"+url.PathEscape(opts.Target)+"/operations", nil)
if err != nil {
return err
}
operations, _ := body["operations"].(map[string]any)
result := map[string]any{"plugin_id": opts.Target}
switch command {
case "logs":
result["logs"] = operations["logs"]
result["traces"] = operations["traces"]
case "events":
result["events"] = operations["events"]
result["event_queue"] = operations["event_queue"]
case "metrics":
result["handlers"] = operations["handlers"]
result["custom_metrics"] = operations["custom_metrics"]
default:
return fmt.Errorf("unknown operations section %q", command)
}
return encodePluginCLIJSON(result)
}
func runPluginRemoteDiagnoseCLI(args []string) error {
opts, err := parsePluginRemoteOptions(args)
if err != nil {
return err
}
if opts.Target == "" {
return errors.New("diagnose requires a plugin id")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
return client.doToStdout(http.MethodGet, "/plugins/"+url.PathEscape(opts.Target)+"/diagnostics", nil)
}
func runPluginRemoteTaskCLI(args []string) error {
if len(args) == 0 {
return errors.New("usage: gateway plugin task list|run|cancel ...")
}
switch args[0] {
case "list":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
if opts.Target == "" {
return errors.New("task list requires a plugin id")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
body, err := client.doJSON(http.MethodGet, "/plugins/"+url.PathEscape(opts.Target)+"/operations", nil)
if err != nil {
return err
}
operations, _ := body["operations"].(map[string]any)
return encodePluginCLIJSON(map[string]any{
"plugin_id": opts.Target,
"background_tasks": operations["background_tasks"],
})
case "run":
opts, err := parsePluginRemoteOptionsWithPositionals(args[1:], 2)
if err != nil {
return err
}
if opts.Target == "" || len(opts.Extra) == 0 {
return errors.New("task run requires a plugin id and task id")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
taskID := opts.Extra[0]
body := map[string]any{"confirm_token": opts.ConfirmToken}
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/operations/tasks/"+url.PathEscape(taskID)+"/trigger", body)
case "cancel":
return runPluginReservedCLI("task cancel", args[1:])
default:
return fmt.Errorf("unknown task command %q", args[0])
}
}
func runPluginRemoteResourceCLI(command string, args []string) error {
if len(args) == 0 {
return fmt.Errorf("usage: gateway plugin %s inspect|gc ...", command)
}
switch args[0] {
case "inspect":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
if opts.Target == "" {
return fmt.Errorf("%s inspect requires a plugin id", command)
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
body, err := client.doJSON(http.MethodGet, "/plugins/"+url.PathEscape(opts.Target)+"/operations", nil)
if err != nil {
return err
}
operations, _ := body["operations"].(map[string]any)
field := "plugin_data"
if command == "files" {
field = "plugin_files"
}
return encodePluginCLIJSON(map[string]any{
"plugin_id": opts.Target,
field: operations[field],
"gc": operations["gc"],
})
case "gc":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
if opts.Target == "" {
return fmt.Errorf("%s gc requires a plugin id", command)
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
method := http.MethodGet
if !opts.DryRun {
method = http.MethodPost
}
return client.doToStdout(method, "/plugins/"+url.PathEscape(opts.Target)+"/operations/gc", nil)
case "export":
return runPluginReservedCLI(command+" export", args[1:])
default:
return fmt.Errorf("unknown %s command %q", command, args[0])
}
}
func runPluginRemoteGCCLI(args []string) error {
opts, err := parsePluginRemoteOptions(args)
if err != nil {
return err
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
method := http.MethodGet
if !opts.DryRun {
method = http.MethodPost
}
endpoint := "/plugin-gc"
if opts.Target != "" {
endpoint = "/plugin-operations-gc?plugin_id=" + url.QueryEscape(opts.Target)
}
return client.doToStdout(method, endpoint, nil)
}
func runPluginRemoteReviewCLI(args []string) error {
if len(args) == 0 {
return errors.New("usage: gateway plugin review status|approve|reject|override ...")
}
switch args[0] {
case "status":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
if opts.Target == "" {
return errors.New("review status requires a plugin id")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
values := url.Values{}
if opts.ArtifactID != "" {
values.Set("artifact_id", opts.ArtifactID)
}
if opts.Profile != "" {
values.Set("profile", opts.Profile)
}
endpoint := "/plugins/" + url.PathEscape(opts.Target) + "/governance"
if query := values.Encode(); query != "" {
endpoint += "?" + query
}
return client.doToStdout(http.MethodGet, endpoint, nil)
case "approve", "reject":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
if opts.Target == "" {
return fmt.Errorf("review %s requires a plugin id", args[0])
}
if opts.ArtifactID == "" {
return fmt.Errorf("review %s requires --artifact", args[0])
}
decision := pluginmanager.ReviewDecisionApproved
if args[0] == "reject" {
decision = pluginmanager.ReviewDecisionRejected
}
if opts.Decision != "" {
decision = opts.Decision
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
body := map[string]any{
"artifact_id": opts.ArtifactID,
"profile": opts.Profile,
"decision": decision,
"notes": opts.Notes,
}
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/governance/review", body)
case "override":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
if opts.Target == "" || opts.ArtifactID == "" {
return errors.New("review override requires a plugin id and --artifact")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
body := map[string]any{
"artifact_id": opts.ArtifactID,
"profile": opts.Profile,
"action": opts.Action,
"reason": opts.Reason,
"ttl_seconds": opts.TTLSeconds,
}
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/governance/override", body)
default:
return fmt.Errorf("unknown review command %q", args[0])
}
}
func runPluginRemoteAdvisoryCLI(args []string) error {
if len(args) == 0 {
return errors.New("usage: gateway plugin advisory scan|import ...")
}
switch args[0] {
case "scan":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
endpoint := "/plugin-advisories"
if opts.Target != "" {
endpoint += "?plugin_id=" + url.QueryEscape(opts.Target)
}
return client.doToStdout(http.MethodGet, endpoint, nil)
case "import":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
body, err := remoteMetadataJSON(opts)
if err != nil {
return err
}
return client.doToStdout(http.MethodPost, "/plugin-advisories", body)
default:
return fmt.Errorf("unknown advisory command %q", args[0])
}
}
func runPluginRemoteRepoCLI(args []string) error {
if len(args) == 0 {
return errors.New("usage: gateway plugin repo list|import|search|show ...")
}
switch args[0] {
case "list":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
return client.doToStdout(http.MethodGet, "/plugin-repositories/imports", nil)
case "import":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
body := map[string]any{
"repository_type": opts.RepositoryType,
"index_path": opts.IndexPath,
"artifact_id": opts.ArtifactID,
"plugin_id": opts.Target,
"version": opts.Version,
"trust_policy": opts.TrustPolicy,
}
return client.doToStdout(http.MethodPost, "/plugin-repositories/imports", body)
case "search", "show":
return runPluginReservedCLI("repo "+args[0], args[1:])
default:
return fmt.Errorf("unknown repo command %q", args[0])
}
}
func runPluginRemoteSupplyChainCLI(command string, args []string) error {
if command == "sbom" {
if len(args) == 0 {
return errors.New("usage: gateway plugin sbom verify|generate ...")
}
switch args[0] {
case "verify":
return runPluginRemoteSupplyChainAssessCLI(args[1:])
case "generate":
return runPluginReservedCLI("sbom generate", args[1:])
default:
return fmt.Errorf("unknown sbom command %q", args[0])
}
}
return runPluginRemoteSupplyChainAssessCLI(args)
}
func runPluginRemoteSupplyChainAssessCLI(args []string) error {
opts, err := parsePluginRemoteOptions(args)
if err != nil {
return err
}
if opts.Target == "" || opts.ArtifactID == "" {
return errors.New("supply-chain verification requires a plugin id and --artifact")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
metadata, err := remoteMetadataJSON(opts)
if err != nil {
return err
}
body := map[string]any{
"plugin_id": opts.Target,
"artifact_id": opts.ArtifactID,
"metadata": metadata,
}
return client.doToStdout(http.MethodPost, "/plugin-supply-chain", body)
}
func runPluginRuntimeCLI(args []string) error {
if len(args) == 0 {
return errors.New("usage: gateway plugin runtime features|status|mode|apply ...")
}
switch args[0] {
case "features":
return runPluginFeaturesCLI(nil)
case "status":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
return client.doToStdout(http.MethodGet, "/plugin-service", nil)
case "mode":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
if opts.Mode == "" {
return errors.New("runtime mode requires --mode")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
return client.doToStdout(http.MethodPut, "/plugin-service", map[string]any{"desired_mode": opts.Mode})
case "apply":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
return client.doToStdout(http.MethodPost, "/plugin-service", nil)
default:
return fmt.Errorf("unknown runtime command %q", args[0])
}
}
func runPluginReservedCLI(command string, args []string) error {
_ = args
return encodePluginCLIJSON(map[string]any{
"command": command,
"status": "reserved",
"message": "command is reserved by the plugin toolchain design but is not implemented in this gateway yet",
})
}
func parsePluginRemoteOptions(args []string) (pluginRemoteOptions, error) {
return parsePluginRemoteOptionsWithPositionals(args, 1)
}
func parsePluginRemoteOptionsWithPositionals(args []string, maxPositionals int) (pluginRemoteOptions, error) {
opts := pluginRemoteOptions{
Gateway: os.Getenv("MC_GATEWAY_ADMIN_URL"),
Token: os.Getenv("MC_GATEWAY_ADMIN_TOKEN"),
Priority: pluginmanager.DefaultPriority,
DryRun: true,
}
var positionals []string
for i := 0; i < len(args); i++ {
arg := args[i]
if !strings.HasPrefix(arg, "--") {
if len(positionals) >= maxPositionals {
return pluginRemoteOptions{}, fmt.Errorf("unexpected argument %q", arg)
}
positionals = append(positionals, arg)
continue
}
key, value, consumed, err := parsePluginCLIFlag(args, i)
if err != nil {
return pluginRemoteOptions{}, err
}
i += consumed
switch key {
case "gateway":
opts.Gateway = value
case "token":
opts.Token = value
case "artifact":
opts.ArtifactID = value
case "config":
opts.ConfigPath = value
case "config-json":
opts.ConfigJSON = value
case "priority":
parsed, err := strconv.Atoi(value)
if err != nil {
return pluginRemoteOptions{}, fmt.Errorf("invalid --priority %q: %w", value, err)
}
opts.Priority = parsed
case "source":
opts.Source = parsePluginBoolFlag(value)
case "snapshot":
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return pluginRemoteOptions{}, fmt.Errorf("invalid --snapshot %q: %w", value, err)
}
opts.SnapshotID = parsed
case "full-desired":
opts.FullDesired = parsePluginBoolFlag(value)
case "profile":
opts.Profile = value
case "action":
opts.Action = value
case "decision":
opts.Decision = value
case "notes":
opts.Notes = value
case "reason":
opts.Reason = value
case "ttl":
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return pluginRemoteOptions{}, fmt.Errorf("invalid --ttl %q: %w", value, err)
}
opts.TTLSeconds = parsed
case "confirm-token":
opts.ConfirmToken = value
case "dry-run":
opts.DryRun = parsePluginBoolFlag(value)
case "repository-type":
opts.RepositoryType = value
case "index":
opts.IndexPath = value
case "version":
opts.Version = value
case "trust-policy":
opts.TrustPolicy = value
case "metadata":
opts.MetadataPath = value
case "metadata-json":
opts.MetadataJSON = value
case "benchmark-profile":
opts.BenchmarkProfile = value
case "p95-ms":
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
return pluginRemoteOptions{}, fmt.Errorf("invalid --p95-ms %q: %w", value, err)
}
opts.P95MS = parsed
case "p99-ms":
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
return pluginRemoteOptions{}, fmt.Errorf("invalid --p99-ms %q: %w", value, err)
}
opts.P99MS = parsed
case "error-rate":
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
return pluginRemoteOptions{}, fmt.Errorf("invalid --error-rate %q: %w", value, err)
}
opts.ErrorRate = parsed
case "active-proxy-capacity":
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return pluginRemoteOptions{}, fmt.Errorf("invalid --active-proxy-capacity %q: %w", value, err)
}
opts.ActiveProxyCapacity = parsed
case "baseline-diff":
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
return pluginRemoteOptions{}, fmt.Errorf("invalid --baseline-diff %q: %w", value, err)
}
opts.BaselineDiff = parsed
case "mode":
opts.Mode = value
default:
return pluginRemoteOptions{}, fmt.Errorf("unknown remote flag --%s", key)
}
}
if len(positionals) > 0 {
opts.Target = positionals[0]
}
if len(positionals) > 1 {
opts.Extra = append(opts.Extra, positionals[1:]...)
}
return opts, nil
}
func newPluginRemoteClient(opts pluginRemoteOptions) (pluginRemoteClient, error) {
if strings.TrimSpace(opts.Gateway) == "" {
return pluginRemoteClient{}, errors.New("--gateway or MC_GATEWAY_ADMIN_URL is required")
}
if strings.TrimSpace(opts.Token) == "" {
return pluginRemoteClient{}, errors.New("--token or MC_GATEWAY_ADMIN_TOKEN is required")
}
base, err := normalizeAdminAPIBase(opts.Gateway)
if err != nil {
return pluginRemoteClient{}, err
}
return pluginRemoteClient{baseURL: base, token: opts.Token, client: http.DefaultClient}, nil
}
func normalizeAdminAPIBase(raw string) (string, error) {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return "", err
}
if parsed.Scheme == "" || parsed.Host == "" {
return "", fmt.Errorf("invalid gateway URL %q", raw)
}
parsed.RawQuery = ""
parsed.Fragment = ""
parsed.Path = strings.TrimRight(parsed.Path, "/")
switch {
case parsed.Path == "":
parsed.Path = "/admin/api"
case strings.HasSuffix(parsed.Path, "/admin/api"):
case strings.HasSuffix(parsed.Path, "/admin"):
parsed.Path = parsed.Path + "/api"
default:
parsed.Path = path.Join(parsed.Path, "admin/api")
}
return parsed.String(), nil
}
func (c pluginRemoteClient) doToStdout(method, endpoint string, body any) error {
data, err := c.doBytes(method, endpoint, body)
if err != nil {
return err
}
return writePluginRemoteData(data)
}
func (c pluginRemoteClient) doJSON(method, endpoint string, body any) (map[string]any, error) {
data, err := c.doBytes(method, endpoint, body)
if err != nil {
return nil, err
}
if len(data) == 0 {
return map[string]any{}, nil
}
var decoded map[string]any
if err := json.Unmarshal(data, &decoded); err != nil {
return nil, fmt.Errorf("admin API response is not a JSON object: %w", err)
}
return decoded, nil
}
func (c pluginRemoteClient) doBytes(method, endpoint string, body any) ([]byte, error) {
var reader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, err
}
reader = bytes.NewReader(data)
}
req, err := http.NewRequest(method, c.baseURL+endpoint, reader)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+c.token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return readPluginRemoteResponse(resp)
}
func (c pluginRemoteClient) uploadArtifact(endpoint, filePath string) error {
var payload bytes.Buffer
writer := multipart.NewWriter(&payload)
part, err := writer.CreateFormFile("artifact", filepath.Base(filePath))
if err != nil {
return err
}
file, err := os.Open(filePath)
if err != nil {
return err
}
if _, err := io.Copy(part, file); err != nil {
_ = file.Close()
return err
}
if err := file.Close(); err != nil {
return err
}
if err := writer.Close(); err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, c.baseURL+endpoint, &payload)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
data, err := readPluginRemoteResponse(resp)
if err != nil {
return err
}
return writePluginRemoteData(data)
}
func readPluginRemoteResponse(resp *http.Response) ([]byte, error) {
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
message := strings.TrimSpace(string(data))
if message == "" {
message = resp.Status
}
return nil, fmt.Errorf("admin API %s: %s", resp.Status, message)
}
return data, nil
}
func writePluginRemoteData(data []byte) error {
if len(data) == 0 {
fmt.Fprintln(os.Stdout, "{}")
return nil
}
var pretty bytes.Buffer
if json.Indent(&pretty, data, "", " ") == nil {
pretty.WriteByte('\n')
_, err := pretty.WriteTo(os.Stdout)
return err
}
_, err := os.Stdout.Write(data)
if err == nil && len(data) > 0 && data[len(data)-1] != '\n' {
fmt.Fprintln(os.Stdout)
}
return err
}
func remoteConfigJSON(opts pluginRemoteOptions) (string, error) {
if opts.ConfigJSON != "" {
if !json.Valid([]byte(opts.ConfigJSON)) {
return "", errors.New("--config-json must be valid JSON")
}
return opts.ConfigJSON, nil
}
if opts.ConfigPath != "" {
data, err := os.ReadFile(opts.ConfigPath)
if err != nil {
return "", err
}
if !json.Valid(data) {
return "", fmt.Errorf("config file %q must contain valid JSON", opts.ConfigPath)
}
return string(data), nil
}
return "{}", nil
}
func remoteMetadataJSON(opts pluginRemoteOptions) (map[string]any, error) {
if opts.MetadataJSON != "" {
var decoded map[string]any
if err := json.Unmarshal([]byte(opts.MetadataJSON), &decoded); err != nil {
return nil, fmt.Errorf("--metadata-json must be a JSON object: %w", err)
}
return decoded, nil
}
if opts.MetadataPath != "" {
data, err := os.ReadFile(opts.MetadataPath)
if err != nil {
return nil, err
}
var decoded map[string]any
if err := json.Unmarshal(data, &decoded); err != nil {
return nil, fmt.Errorf("metadata file %q must contain a JSON object: %w", opts.MetadataPath, err)
}
return decoded, nil
}
return map[string]any{}, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,552 @@
// cmd/gateway/plugin_cli_toolchain_test.go 包含用于约束 plugin cli toolchain 行为的测试。
package main
import (
"archive/zip"
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestPluginInitCreatesBuildableTemplate(t *testing.T) {
dir := filepath.Join(t.TempDir(), "sample-plugin")
handled, code := runPluginCLI([]string{
"plugin", "init", dir,
"--id", "sample-plugin",
"--module", "example.com/sample-plugin",
})
if !handled {
t.Fatal("runPluginCLI() handled = false")
}
if code != 0 {
t.Fatalf("runPluginCLI(init) code = %d, want 0", code)
}
for _, name := range []string{"manifest.yaml", "go.mod", "main.go", "main_test.go", "README.md", "testdata/config.json"} {
if _, err := os.Stat(filepath.Join(dir, filepath.FromSlash(name))); err != nil {
t.Fatalf("generated file %s stat error = %v", name, err)
}
}
if _, err := os.Stat(filepath.Join(dir, "manifest.json")); err == nil {
t.Fatal("plugin init generated manifest.json by default, want manifest.yaml")
}
if _, err := validatePluginDirectoryForCLI(dir, ""); err != nil {
t.Fatalf("validatePluginDirectoryForCLI() error = %v", err)
}
}
func TestPluginBuildSourcePackagesTemplate(t *testing.T) {
dir := filepath.Join(t.TempDir(), "source-plugin")
handled, code := runPluginCLI([]string{
"plugin", "init", dir,
"--id", "source-plugin",
"--module", "example.com/source-plugin",
})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(init) = (%v, %d), want handled code 0", handled, code)
}
out := filepath.Join(t.TempDir(), "source-plugin.mcgp")
handled, code = runPluginCLI([]string{
"plugin", "build", dir,
"--type", "source",
"--out", out,
"--skip-tests",
"--vendor=false",
})
if !handled {
t.Fatal("runPluginCLI() handled = false")
}
if code != 0 {
t.Fatalf("runPluginCLI(build source) code = %d, want 0", code)
}
if _, err := validatePluginPathForCLI(out, "source"); err != nil {
t.Fatalf("validatePluginPathForCLI(source) error = %v", err)
}
assertZipContains(t, out, "manifest.json", "go.mod", "main.go", "main_test.go", "README.md", "testdata/config.json")
assertZipNotContains(t, out, "manifest.yaml")
}
func TestPluginBuildBothAcceptsOutDirectory(t *testing.T) {
dir := filepath.Join(t.TempDir(), "both-plugin")
handled, code := runPluginCLI([]string{
"plugin", "init", dir,
"--id", "both-plugin",
"--module", "example.com/both-plugin",
})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(init) = (%v, %d), want handled code 0", handled, code)
}
outDir := filepath.Join(t.TempDir(), "packages")
handled, code = runPluginCLI([]string{
"plugin", "build", dir,
"--type", "both",
"--out", outDir,
"--skip-tests",
"--vendor=false",
})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(build both) = (%v, %d), want handled code 0", handled, code)
}
binaryOut := filepath.Join(outDir, "both-plugin.mcgp")
sourceOut := filepath.Join(outDir, "both-plugin-source.mcgp")
if _, err := validatePluginPathForCLI(binaryOut, "binary"); err != nil {
t.Fatalf("validatePluginPathForCLI(binary) error = %v", err)
}
if _, err := validatePluginPathForCLI(sourceOut, "source"); err != nil {
t.Fatalf("validatePluginPathForCLI(source) error = %v", err)
}
assertZipNotContains(t, binaryOut, "manifest.yaml")
assertZipNotContains(t, sourceOut, "manifest.yaml")
}
func TestPluginTestManifestProfile(t *testing.T) {
dir := filepath.Join(t.TempDir(), "test-plugin")
handled, code := runPluginCLI([]string{
"plugin", "init", dir,
"--id", "test-plugin",
"--module", "example.com/test-plugin",
})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(init) = (%v, %d), want handled code 0", handled, code)
}
handled, code = runPluginCLI([]string{"plugin", "test", dir, "--profile", "manifest"})
if !handled {
t.Fatal("runPluginCLI() handled = false")
}
if code != 0 {
t.Fatalf("runPluginCLI(test manifest) code = %d, want 0", code)
}
}
func TestPluginFeaturesAndManifestCommands(t *testing.T) {
handled, code := runPluginCLI([]string{"plugin", "features"})
if !handled {
t.Fatal("runPluginCLI() handled = false")
}
if code != 0 {
t.Fatalf("runPluginCLI(features) code = %d, want 0", code)
}
handled, code = runPluginCLI([]string{"plugin", "manifest", "explain", "upstream.connect/v1"})
if !handled {
t.Fatal("runPluginCLI() handled = false")
}
if code != 0 {
t.Fatalf("runPluginCLI(manifest explain) code = %d, want 0", code)
}
}
func TestPluginManifestFormatWrite(t *testing.T) {
dir := filepath.Join(t.TempDir(), "format-plugin")
handled, code := runPluginCLI([]string{
"plugin", "init", dir,
"--id", "format-plugin",
"--module", "example.com/format-plugin",
"--manifest-format", "json",
})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(init) = (%v, %d), want handled code 0", handled, code)
}
manifestPath := filepath.Join(dir, "manifest.json")
if err := os.WriteFile(manifestPath, []byte(`{"schema_version":"mc-gateway.plugin/v1","id":"format-plugin","name":"Format Plugin","version":"0.1.0","artifact_type":"source","runtime":{"type":"go-plugin","entry":"plugin.so","entry_symbol":"Plugin"},"build":{"type":"go","entry":".","output":"plugin.so"},"api_version":"plugin-api/v1","sdk_module":"github.com/tursom/mc-gateway/plugin/api","sdk_module_version":"v0.1.0","extension_points":[{"type":"hook","key":"upstream.connect/v1"}],"capabilities":{"upstream_connect":{"mode":"dialer"}},"runtime_limits":{"handler_timeout_ms":3000},"config_schema":{"type":"object"}}`), 0644); err != nil {
t.Fatalf("WriteFile(manifest) error = %v", err)
}
handled, code = runPluginCLI([]string{"plugin", "manifest", "format", dir, "--write"})
if !handled {
t.Fatal("runPluginCLI() handled = false")
}
if code != 0 {
t.Fatalf("runPluginCLI(manifest format) code = %d, want 0", code)
}
data, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatalf("ReadFile(manifest) error = %v", err)
}
if !strings.Contains(string(data), "\n \"schema_version\"") {
t.Fatalf("manifest was not formatted:\n%s", data)
}
}
func TestPluginManifestSourceFormats(t *testing.T) {
for _, format := range []string{"yaml", "toml", "jsonc", "json"} {
t.Run(format, func(t *testing.T) {
dir := filepath.Join(t.TempDir(), "format-"+format)
handled, code := runPluginCLI([]string{
"plugin", "init", dir,
"--id", "format-" + format,
"--module", "example.com/format-" + format,
"--manifest-format", format,
})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(init %s) = (%v, %d), want handled code 0", format, handled, code)
}
source, err := readPluginManifestSource(dir, "")
if err != nil {
t.Fatalf("readPluginManifestSource(%s) error = %v", format, err)
}
if source.Manifest.ID != "format-"+format {
t.Fatalf("manifest id = %q, want format-%s", source.Manifest.ID, format)
}
if !json.Valid(source.CanonicalJSON) {
t.Fatalf("canonical JSON for %s is invalid:\n%s", format, source.CanonicalJSON)
}
packaged, err := materializedManifestJSON(source.Raw, source.Manifest, "binary", false)
if err != nil {
t.Fatalf("materializedManifestJSON(%s) error = %v", format, err)
}
var raw map[string]any
if err := json.Unmarshal(packaged, &raw); err != nil {
t.Fatalf("Unmarshal(materialized %s) error = %v", format, err)
}
if raw["artifact_type"] != "binary" || raw["go_version"] == "" || raw["go_os"] == "" || raw["go_arch"] == "" {
t.Fatalf("materialized %s manifest missing package fields: %s", format, packaged)
}
handled, code = runPluginCLI([]string{"plugin", "validate", dir})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(validate %s) = (%v, %d), want handled code 0", format, handled, code)
}
handled, code = runPluginCLI([]string{"plugin", "test", dir, "--profile", "manifest"})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(test %s) = (%v, %d), want handled code 0", format, handled, code)
}
})
}
}
func TestPluginManifestFormatWritePreservesComments(t *testing.T) {
for _, tc := range []struct {
name string
file string
content string
comment string
}{
{
name: "yaml",
file: "manifest.yaml",
content: "# keep yaml comment\n" + manifestYAMLTemplate(pluginInitCLIOptions{ID: "comment-yaml", Name: "Comment YAML", Extension: "upstream.connect/v1"}),
comment: "# keep yaml comment",
},
{
name: "toml",
file: "manifest.toml",
content: "# keep toml comment\n" + manifestTOMLTemplate(pluginInitCLIOptions{ID: "comment-toml", Name: "Comment TOML", Extension: "upstream.connect/v1"}),
comment: "# keep toml comment",
},
{
name: "jsonc",
file: "manifest.jsonc",
content: strings.Replace(
"// keep jsonc comment\n"+strings.TrimSuffix(manifestSourceJSONTemplate(pluginInitCLIOptions{ID: "comment-jsonc", Name: "Comment JSONC", Extension: "upstream.connect/v1"}, true), "\n"),
"\n }\n}",
"\n },\n}",
1,
),
comment: "// keep jsonc comment",
},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
manifestPath := filepath.Join(dir, tc.file)
if err := os.WriteFile(manifestPath, []byte(tc.content), 0644); err != nil {
t.Fatalf("WriteFile(%s) error = %v", tc.file, err)
}
before, err := readPluginManifestSource(dir, "")
if err != nil {
t.Fatalf("readPluginManifestSource(before) error = %v", err)
}
handled, code := runPluginCLI([]string{"plugin", "manifest", "format", dir, "--write"})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(manifest format %s) = (%v, %d), want handled code 0", tc.name, handled, code)
}
data, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", tc.file, err)
}
if !strings.Contains(string(data), tc.comment) {
t.Fatalf("formatted %s lost comment:\n%s", tc.file, data)
}
after, err := readPluginManifestSource(dir, "")
if err != nil {
t.Fatalf("readPluginManifestSource(after) error = %v", err)
}
if !bytes.Equal(before.CanonicalJSON, after.CanonicalJSON) {
t.Fatalf("canonical JSON changed after format\nbefore=%s\nafter=%s", before.CanonicalJSON, after.CanonicalJSON)
}
})
}
}
func TestPluginManifestMultipleSourcesRequireExplicitManifest(t *testing.T) {
dir := filepath.Join(t.TempDir(), "multi-manifest")
handled, code := runPluginCLI([]string{
"plugin", "init", dir,
"--id", "multi-manifest",
"--module", "example.com/multi-manifest",
})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(init) = (%v, %d), want handled code 0", handled, code)
}
if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifestSourceJSONTemplate(pluginInitCLIOptions{ID: "multi-manifest", Name: "Multi Manifest", Extension: "upstream.connect/v1"}, false)), 0644); err != nil {
t.Fatalf("WriteFile(manifest.json) error = %v", err)
}
handled, code = runPluginCLI([]string{"plugin", "validate", dir})
if !handled {
t.Fatal("runPluginCLI(validate) handled = false")
}
if code == 0 {
t.Fatal("runPluginCLI(validate) code = 0, want failure for multiple manifests")
}
handled, code = runPluginCLI([]string{"plugin", "validate", dir, "--manifest", "manifest.yaml"})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(validate --manifest) = (%v, %d), want handled code 0", handled, code)
}
handled, code = runPluginCLI([]string{"plugin", "test", dir, "--profile", "manifest", "--manifest", "manifest.yaml"})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(test --manifest) = (%v, %d), want handled code 0", handled, code)
}
out := filepath.Join(t.TempDir(), "multi-manifest-source.mcgp")
handled, code = runPluginCLI([]string{"plugin", "build", dir, "--type", "source", "--out", out, "--skip-tests", "--vendor=false", "--manifest", "manifest.yaml"})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(build --manifest) = (%v, %d), want handled code 0", handled, code)
}
if _, err := validatePluginPathForCLI(out, "source"); err != nil {
t.Fatalf("validatePluginPathForCLI(source) error = %v", err)
}
handled, code = runPluginCLI([]string{"plugin", "manifest", "format", dir, "--manifest", "manifest.yaml", "--canonical-json", "--type", "source"})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(manifest format --manifest --canonical-json) = (%v, %d), want handled code 0", handled, code)
}
}
func TestPluginGovernanceCommands(t *testing.T) {
dir := filepath.Join(t.TempDir(), "governance-plugin")
handled, code := runPluginCLI([]string{
"plugin", "init", dir,
"--id", "governance-plugin",
"--module", "example.com/governance-plugin",
})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(init) = (%v, %d), want handled code 0", handled, code)
}
artifact := filepath.Join(t.TempDir(), "governance-plugin.mcgp")
handled, code = runPluginCLI([]string{
"plugin", "build", dir,
"--type", "binary",
"--out", artifact,
"--skip-tests",
})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(build binary) = (%v, %d), want handled code 0", handled, code)
}
for _, tc := range [][]string{
{"plugin", "preflight", artifact, "--config-json", `{"upstream":"127.0.0.1:25566"}`, "--profile", "dev"},
{"plugin", "self-test", artifact, "--profile", "dev"},
{"plugin", "benchmark", artifact, "--profile", "dev", "--benchmark-profile", "local-fast", "--p95-ms", "1", "--p99-ms", "2", "--error-rate", "0", "--baseline-diff", "0.1"},
{"plugin", "preflight", dir, "--manifest", "manifest.yaml", "--config-json", `{"upstream":"127.0.0.1:25566"}`, "--profile", "dev"},
} {
handled, code = runPluginCLI(tc)
if !handled {
t.Fatalf("runPluginCLI(%v) handled = false", tc)
}
if code != 0 {
t.Fatalf("runPluginCLI(%v) code = %d, want 0", tc, code)
}
}
}
func TestPluginRemoteCLIRequests(t *testing.T) {
type observedRequest struct {
Method string
RequestURI string
ContentType string
Body map[string]any
FileName string
}
requests := make(chan observedRequest, 16)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
t.Errorf("Authorization = %q, want bearer token", got)
}
observed := observedRequest{
Method: r.Method,
RequestURI: r.URL.RequestURI(),
ContentType: r.Header.Get("Content-Type"),
Body: map[string]any{},
}
switch {
case strings.HasPrefix(observed.ContentType, "application/json"):
if err := json.NewDecoder(r.Body).Decode(&observed.Body); err != nil {
t.Errorf("Decode JSON body error = %v", err)
}
case strings.HasPrefix(observed.ContentType, "multipart/form-data"):
if err := r.ParseMultipartForm(64 << 20); err != nil {
t.Errorf("ParseMultipartForm error = %v", err)
} else {
file, header, err := r.FormFile("artifact")
if err != nil {
t.Errorf("FormFile(artifact) error = %v", err)
} else {
observed.FileName = header.Filename
_, _ = io.Copy(io.Discard, file)
_ = file.Close()
}
}
}
requests <- observed
w.Header().Set("Content-Type", "application/json")
if strings.HasSuffix(r.URL.Path, "/operations") {
_, _ = io.WriteString(w, `{"operations":{"logs":[{"message":"ok"}],"traces":[],"events":[{"name":"evt"}],"event_queue":{"queued":1},"handlers":[{"plugin_id":"demo"}],"custom_metrics":[],"background_tasks":[{"id":"sync"}],"plugin_data":[{"key":"k"}],"plugin_files":[{"name":"f"}],"gc":[]}}`)
return
}
_, _ = io.WriteString(w, `{"ok":true}`)
}))
defer server.Close()
t.Setenv("MC_GATEWAY_ADMIN_URL", server.URL)
t.Setenv("MC_GATEWAY_ADMIN_TOKEN", "test-token")
runRemotePluginCLI(t, "plugin", "status", "demo")
assertRemoteRequest(t, <-requests, http.MethodGet, "/admin/api/plugins/demo")
artifactPath := filepath.Join(t.TempDir(), "demo.mcgp")
if err := os.WriteFile(artifactPath, []byte("artifact"), 0644); err != nil {
t.Fatalf("WriteFile(artifact) error = %v", err)
}
runRemotePluginCLI(t, "plugin", "upload", artifactPath)
uploadReq := <-requests
assertRemoteRequest(t, uploadReq, http.MethodPost, "/admin/api/plugin-artifacts")
if uploadReq.FileName != "demo.mcgp" {
t.Fatalf("upload file name = %q, want demo.mcgp", uploadReq.FileName)
}
configPath := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(configPath, []byte(`{"upstream":"127.0.0.1:25565"}`), 0644); err != nil {
t.Fatalf("WriteFile(config) error = %v", err)
}
runRemotePluginCLI(t, "plugin", "enable", "demo", "--artifact", "art-1", "--config", configPath, "--priority", "7")
enableReq := <-requests
assertRemoteRequest(t, enableReq, http.MethodPut, "/admin/api/plugins/demo")
if enableReq.Body["artifact_id"] != "art-1" || enableReq.Body["desired_state"] != "enabled" || enableReq.Body["priority"].(float64) != 7 {
t.Fatalf("enable body = %#v", enableReq.Body)
}
runRemotePluginCLI(t, "plugin", "logs", "demo")
assertRemoteRequest(t, <-requests, http.MethodGet, "/admin/api/plugins/demo/operations")
runRemotePluginCLI(t, "plugin", "task", "run", "demo", "sync", "--confirm-token", "confirm")
taskReq := <-requests
assertRemoteRequest(t, taskReq, http.MethodPost, "/admin/api/plugins/demo/operations/tasks/sync/trigger")
if taskReq.Body["confirm_token"] != "confirm" {
t.Fatalf("task body = %#v", taskReq.Body)
}
runRemotePluginCLI(t, "plugin", "repo", "import", "demo", "--repository-type", "file", "--index", "repo.json", "--artifact", "candidate-1", "--version", "1.2.3")
repoReq := <-requests
assertRemoteRequest(t, repoReq, http.MethodPost, "/admin/api/plugin-repositories/imports")
if repoReq.Body["plugin_id"] != "demo" || repoReq.Body["repository_type"] != "file" || repoReq.Body["artifact_id"] != "candidate-1" {
t.Fatalf("repo body = %#v", repoReq.Body)
}
runRemotePluginCLI(t, "plugin", "review", "status", "demo", "--artifact", "art-1", "--profile", "prod")
assertRemoteRequest(t, <-requests, http.MethodGet, "/admin/api/plugins/demo/governance?artifact_id=art-1&profile=prod")
runRemotePluginCLI(t, "plugin", "sbom", "verify", "demo", "--artifact", "art-1", "--metadata-json", `{"sbom":{"format":"spdx"}}`)
supplyReq := <-requests
assertRemoteRequest(t, supplyReq, http.MethodPost, "/admin/api/plugin-supply-chain")
if supplyReq.Body["plugin_id"] != "demo" || supplyReq.Body["artifact_id"] != "art-1" {
t.Fatalf("supply-chain body = %#v", supplyReq.Body)
}
runRemotePluginCLI(t, "plugin", "runtime", "mode", "--mode", "go-plugin-process")
runtimeReq := <-requests
assertRemoteRequest(t, runtimeReq, http.MethodPut, "/admin/api/plugin-service")
if runtimeReq.Body["desired_mode"] != "go-plugin-process" {
t.Fatalf("runtime body = %#v", runtimeReq.Body)
}
}
func TestNormalizeAdminAPIBase(t *testing.T) {
for _, tc := range []struct {
raw string
want string
}{
{raw: "http://127.0.0.1:8080", want: "http://127.0.0.1:8080/admin/api"},
{raw: "http://127.0.0.1:8080/admin", want: "http://127.0.0.1:8080/admin/api"},
{raw: "http://127.0.0.1:8080/admin/api/", want: "http://127.0.0.1:8080/admin/api"},
} {
got, err := normalizeAdminAPIBase(tc.raw)
if err != nil {
t.Fatalf("normalizeAdminAPIBase(%q) error = %v", tc.raw, err)
}
if got != tc.want {
t.Fatalf("normalizeAdminAPIBase(%q) = %q, want %q", tc.raw, got, tc.want)
}
}
}
func runRemotePluginCLI(t *testing.T, args ...string) {
t.Helper()
handled, code := runPluginCLI(args)
if !handled {
t.Fatalf("runPluginCLI(%v) handled = false", args)
}
if code != 0 {
t.Fatalf("runPluginCLI(%v) code = %d, want 0", args, code)
}
}
func assertRemoteRequest(t *testing.T, got struct {
Method string
RequestURI string
ContentType string
Body map[string]any
FileName string
}, wantMethod, wantURI string) {
t.Helper()
if got.Method != wantMethod || got.RequestURI != wantURI {
t.Fatalf("request = %s %s, want %s %s", got.Method, got.RequestURI, wantMethod, wantURI)
}
}
func assertZipContains(t *testing.T, zipPath string, names ...string) {
t.Helper()
reader, err := zip.OpenReader(zipPath)
if err != nil {
t.Fatalf("OpenReader(%s) error = %v", zipPath, err)
}
defer reader.Close()
seen := make(map[string]bool, len(reader.File))
for _, file := range reader.File {
seen[file.Name] = true
if strings.Contains(file.Name, `\`) {
t.Fatalf("zip entry %q uses backslash", file.Name)
}
}
for _, name := range names {
if !seen[name] {
t.Fatalf("zip %s missing entry %s; entries=%v", zipPath, name, seen)
}
}
}
func assertZipNotContains(t *testing.T, zipPath string, names ...string) {
t.Helper()
reader, err := zip.OpenReader(zipPath)
if err != nil {
t.Fatalf("OpenReader(%s) error = %v", zipPath, err)
}
defer reader.Close()
seen := make(map[string]bool, len(reader.File))
for _, file := range reader.File {
seen[file.Name] = true
}
for _, name := range names {
if seen[name] {
t.Fatalf("zip %s unexpectedly contains entry %s", zipPath, name)
}
}
}

View File

@@ -1,3 +1,5 @@
// cmd/gateway/plugin_test.go 包含用于约束 plugin 行为的测试。
package main
import (

View File

@@ -1,3 +1,5 @@
// cmd/gateway/quic.go 启动可选的 QUIC 监听器,并把 QUIC 流适配到普通网关连接流程。
package main
import (
@@ -19,6 +21,8 @@ import (
)
type (
// quicConn 把 QUIC connection 和单条 stream 组合成 net.Conn 风格对象,
// 使后续转发逻辑不用区分 TCP 与 QUIC。
quicConn struct {
quic.Connection
quic.Stream
@@ -30,6 +34,7 @@ func runQuic(wg *sync.WaitGroup) {
defer wg.Done()
}
// QUIC 基于 UDP 监听,端口来自运行态服务配置。
udpConn, err := net.ListenUDP("udp4", &net.UDPAddr{Port: config.Quic.Port})
if err != nil {
log.Panic().Err(err).Msg("Failed to listen UDP")
@@ -41,6 +46,7 @@ func runQuic(wg *sync.WaitGroup) {
log.Panic().Err(err).Msg("Failed to generate TLS config")
}
// quic-go 的 listener 接收 connection真正的字节流在 stream 中。
ln, err := quic.Listen(udpConn, tlsConf, nil)
if err != nil {
log.Panic().Err(err).Msg("Failed to listen QUIC")
@@ -63,11 +69,13 @@ func runQuic(wg *sync.WaitGroup) {
func upstreamQuic(host string) net.Conn {
tlsConf := &tls.Config{
// 网关自管的 QUIC 上游默认使用临时证书,当前先跳过证书校验。
InsecureSkipVerify: true, // 跳过证书检查
NextProtos: getQuicNextProtos(),
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) // 3s handshake timeout
// 上游握手使用短超时,避免连接协程在不可达上游上长期等待。
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
conn, err := quic.DialAddr(ctx, host, tlsConf, nil)
@@ -86,6 +94,7 @@ func upstreamQuic(host string) net.Conn {
}
log.Debug().Str("host", host).Msg("QUIC stream opened")
// 返回的 quicConn 后续会收到 Minecraft 首包回放并进入普通双向转发。
return quicConn{
Connection: conn,
Stream: stream,
@@ -95,6 +104,7 @@ func upstreamQuic(host string) net.Conn {
func handleQuicRequest(conn quic.Connection) {
defer conn.CloseWithError(0, "Closing connection")
// 入口连接只等待第一条 stream该 stream 承载完整 Minecraft 字节流。
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
@@ -111,33 +121,33 @@ func handleQuicRequest(conn quic.Connection) {
}
func generateTLSConfig() (*tls.Config, error) {
// 生成私钥
// 生成临时私钥;当前 QUIC 入口不依赖磁盘证书文件。
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
// 创建证书模板
// 创建自签证书模板,满足 QUIC TLS 握手要求。
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
Organization: []string{"Example Org"},
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour), // 有效期 1 年
NotAfter: time.Now().Add(365 * 24 * time.Hour), // 有效期 1 年
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
// 自签名证书
// 自签名证书用于当前进程生命周期内的 QUIC 监听。
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
return nil, err
}
// 编码证书和私钥
// 编码证书和私钥,再交给 tls.X509KeyPair 解析为标准证书结构。
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyPEM, err := x509.MarshalECPrivateKey(priv)
if err != nil {
@@ -145,13 +155,13 @@ func generateTLSConfig() (*tls.Config, error) {
}
keyPEMBlock := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyPEM})
// 加载到 tls.Certificate
// 加载到 tls.Certificate
cert, err := tls.X509KeyPair(certPEM, keyPEMBlock)
if err != nil {
return nil, err
}
// 返回 tls.Config
// 返回 QUIC listener 使用的 TLS 配置。
return &tls.Config{
Certificates: []tls.Certificate{cert},
NextProtos: getQuicNextProtos(),
@@ -161,7 +171,7 @@ func generateTLSConfig() (*tls.Config, error) {
func getQuicNextProtos() []string {
nextProtos := config.Quic.ApplicationProtocols
if len(nextProtos) == 0 {
return []string{"minecraft", "quic", "raw", "h3"} // 默认协议
return []string{"minecraft", "quic", "raw", "h3"} // 默认协议列表。
}
return nextProtos
}
@@ -172,10 +182,12 @@ func (c quicConn) Close() error {
}
func (c quicConn) CloseWrite() error {
// QUIC stream 关闭写方向即可通知对端没有更多数据。
return c.Stream.Close()
}
func (c quicConn) CloseRead() error {
// CancelRead 用于停止接收方向,匹配 relay.go 中的半关闭调用。
c.Stream.CancelRead(0)
return nil
}

View File

@@ -1,3 +1,5 @@
// cmd/gateway/quic_test.go 包含用于约束 quic 行为的测试。
package main
import (

View File

@@ -1,3 +1,5 @@
// cmd/gateway/relay.go 实现客户端与上游之间的双向复制循环和转发缓冲池。
package main
import (
@@ -10,6 +12,7 @@ import (
const proxyBufferSize = 64 * 1024
// proxyBufferPool 为普通 io.CopyBuffer 路径复用 64KiB 缓冲区,降低长连接转发时的分配压力。
var proxyBufferPool = sync.Pool{
New: func() any {
buf := make([]byte, proxyBufferSize)
@@ -30,6 +33,7 @@ type (
func proxyConnections(a, b io.ReadWriter) {
var wg sync.WaitGroup
// 两个方向独立复制,任意一侧读到 EOF 后通过半关闭通知对端。
wg.Add(2)
go func() {
defer wg.Done()
@@ -44,6 +48,7 @@ func proxyConnections(a, b io.ReadWriter) {
}
func proxyCopy(dst io.Writer, src io.Reader) {
// 转发协程不能把 panic 带出到连接处理主协程;记录后关闭对应方向即可。
defer recoverProxyCopy()
defer closeRead(src)
defer closeWrite(dst)
@@ -55,6 +60,8 @@ func proxyCopy(dst io.Writer, src io.Reader) {
}
func copyForward(dst io.Writer, src io.Reader) (int64, error) {
// 优先使用标准库为具体类型提供的零拷贝/优化路径,只有普通 reader/writer
// 才落到共享缓冲区。
if _, ok := src.(io.WriterTo); ok {
return io.Copy(dst, src)
}
@@ -81,6 +88,7 @@ func putProxyBuffer(buf []byte) {
}
func writeAll(w io.Writer, buf []byte) error {
// net.Conn.Write 允许短写;首包回放和 PROXY 头写入必须循环直到写完。
for len(buf) > 0 {
n, err := w.Write(buf)
if n > 0 {
@@ -98,6 +106,7 @@ func writeAll(w io.Writer, buf []byte) error {
}
func closeWrite(conn any) {
// TCP 支持半关闭时只关闭写方向,让反向复制还有机会读完剩余数据。
if closer, ok := conn.(closeWriter); ok {
if err := closer.CloseWrite(); err != nil {
log.Debug().Err(err).Msg("failed to close write side")
@@ -113,6 +122,7 @@ func closeWrite(conn any) {
}
func closeRead(conn any) {
// 支持 CloseRead 的连接可以显式停止读方向,帮助对端更快感知转发结束。
if closer, ok := conn.(closeReader); ok {
if err := closer.CloseRead(); err != nil {
log.Debug().Err(err).Msg("failed to close read side")

View File

@@ -1,3 +1,5 @@
// cmd/gateway/relay_benchmark_test.go 包含用于约束 relay benchmark 行为的测试。
package main
import (

View File

@@ -1,3 +1,5 @@
// cmd/gateway/relay_test.go 包含用于约束 relay 行为的测试。
package main
import (

View File

@@ -1,3 +1,5 @@
// cmd/gateway/tcp.go 在未与 Admin HTTP 共用端口时启动普通 TCP Minecraft 监听器。
package main
import (
@@ -34,13 +36,14 @@ func runTcp(wg *sync.WaitGroup) {
continue
}
setSocketOptions(conn)
// 处理连接
// 处理连接;后续握手解析、插件过滤和路由解析都在 handleRequest 中完成。
gatewayMetrics.TCPConnectionStarted()
go handleRequest(conn)
}
}
func upstreamTcp(host string) net.Conn {
// TCP 是默认上游传输,路由值没有协议前缀时都会走这里。
conn, err := tcpDialer.Dial("tcp", host)
if err != nil {
gatewayMetrics.UpstreamDialError()
@@ -53,13 +56,14 @@ func upstreamTcp(host string) net.Conn {
}
var tcpDialer = net.Dialer{
// 上游拨号失败应尽快返回给客户端连接处理流程,避免连接协程长期堆积。
Timeout: 3 * time.Second,
KeepAlive: 30 * time.Second,
}
func setSocketOptions(conn net.Conn) {
if tcpConn, ok := conn.(*net.TCPConn); ok {
tcpConn.SetNoDelay(true) // 禁用 Nagle 算法
tcpConn.SetNoDelay(true) // 禁用 Nagle 算法,降低 Minecraft 交互延迟。
tcpConn.SetKeepAlive(true)
tcpConn.SetKeepAlivePeriod(30 * time.Second)
}

View File

@@ -1,3 +1,5 @@
// cmd/gateway/tcp_test.go 包含用于约束 tcp 行为的测试。
package main
import (

View File

@@ -1,3 +1,5 @@
// cmd/gateway/tcp_web_port_reuse.go 启动共享 TCP/Admin 监听器,按连接首包自动区分 HTTP 流量和 Minecraft 流量。
package main
import (
@@ -12,11 +14,13 @@ import (
)
const (
defaultTCPPort = 25565
defaultTCPPort = 25565
// 首包超时沿用 tcphttpmux 默认值,保持同端口分流逻辑的单一来源。
tcpWebInitialPacketTimeout = tcphttpmux.DefaultInitialPacketTimeout
)
func normalizedTCPPort() int {
// 静态配置未指定端口时保持 Minecraft 默认端口。
if config.Tcp.Port == 0 {
return defaultTCPPort
}
@@ -31,6 +35,7 @@ func normalizedWebSocketPort() int {
}
func normalizedWebSocketPath() string {
// WebSocket 路径为空时回退到根路径,避免生成空的 HTTP 路由。
if config.WebSocket.Path == "" {
return "/"
}
@@ -38,6 +43,7 @@ func normalizedWebSocketPath() string {
}
func tcpWebPortReuseEnabled() bool {
// 是否共用端口完全由启用状态和端口相等推导,不引入额外配置开关。
return config.Tcp.Enable &&
config.WebSocket.Enable &&
normalizedTCPPort() == normalizedWebSocketPort()
@@ -49,6 +55,7 @@ func runTcpWebPortReuse(wg *sync.WaitGroup) {
}
port := normalizedTCPPort()
// 同一个 listener 同时承载 Minecraft TCP 和 Admin HTTP由 serveTcpWebPortReuse 分流。
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil {
log.Fatal().Err(err).
@@ -69,6 +76,7 @@ func runTcpWebPortReuse(wg *sync.WaitGroup) {
}
func serveTcpWebPortReuse(listener net.Listener, handler http.Handler, tcpHandler func(net.Conn)) error {
// tcphttpmux 只负责协议分流指标、socket 选项和日志通过回调接回主包。
return tcphttpmux.Serve(listener, handler, tcpHandler, tcphttpmux.Options{
InitialPacketTimeout: tcpWebInitialPacketTimeout,
SetSocketOptions: setSocketOptions,

View File

@@ -1,3 +1,5 @@
// cmd/gateway/tcp_web_port_reuse_test.go 包含用于约束 tcp web port reuse 行为的测试。
package main
import (

View File

@@ -1,3 +1,5 @@
// cmd/gateway/test_helpers_test.go 包含用于约束 test helpers 行为的测试。
package main
import (

View File

@@ -1,3 +1,5 @@
// cmd/gateway/websocket.go 把 WebSocket 会话适配为 net.Conn让浏览器客户端复用网关请求路径。
package main
import (
@@ -14,7 +16,7 @@ import (
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
// 允许所有来源的连接(生产环境中应该更严格)
// 当前网关把 WebSocket 当作传输层入口,先允许所有来源;生产暴露时应在反向代理层收紧来源。
return true
},
}
@@ -38,12 +40,14 @@ func handleWebSocket(w http.ResponseWriter, r *http.Request) {
defer conn.Close()
gatewayMetrics.WebSocketConnectionStarted()
// WebSocket 连接包装为 net.Conn 后进入同一个 handleRequest复用插件、路由和转发逻辑。
handleRequest(&webSocketConn{Conn: conn})
}
func (w *webSocketConn) Read(b []byte) (n int, err error) {
for {
if w.reader != nil {
// 当前消息帧没读完前持续从同一个 reader 读取,模拟流式 net.Conn。
n, err = w.reader.Read(b)
if errors.Is(err, io.EOF) {
w.reader = nil
@@ -60,6 +64,7 @@ func (w *webSocketConn) Read(b []byte) (n int, err error) {
return 0, err
}
if messageType != websocket.BinaryMessage && messageType != websocket.TextMessage {
// 控制帧不进入 Minecraft 协议流。
continue
}
w.reader = reader
@@ -67,6 +72,7 @@ func (w *webSocketConn) Read(b []byte) (n int, err error) {
}
func (w *webSocketConn) Write(b []byte) (n int, err error) {
// 每次 Write 输出一个二进制 WebSocket 消息,保持与 Minecraft packet 边界无关的字节流语义。
writer, err := w.NextWriter(websocket.BinaryMessage)
if err != nil {
return 0, err
@@ -96,6 +102,7 @@ func (w *webSocketConn) SetDeadline(t time.Time) error {
func newWebSocketHandler() http.Handler {
mux := http.NewServeMux()
// 路径来自运行态服务配置,允许管理端把 WebSocket 入口挂到子路径。
mux.HandleFunc(normalizedWebSocketPath(), handleWebSocket)
return mux
}

View File

@@ -1,3 +1,5 @@
// cmd/gateway/websocket_test.go 包含用于约束 websocket 行为的测试。
package main
import (

View File

@@ -1,3 +1,5 @@
// cmd/kcp/main.go 提供独立的 KCP 到 TCP 代理工具,用于测试或演示 KCP 传输行为。
package main
import (

View File

@@ -1,3 +1,5 @@
// cmd/kcp/main_test.go 包含用于约束 kcp 行为的测试。
package main
import (

View File

@@ -1,3 +1,5 @@
// cmd/quic/main.go 提供独立的 QUIC 到 TCP 代理工具,用于测试或演示 QUIC 传输行为。
package main
import (
@@ -84,7 +86,7 @@ func handlerConn(conn net.Conn) {
log.Info().
Msg("QUIC stream opened")
// read and write stream data
// 读写 QUIC 流数据。
buf := make([]byte, 1024)
n, err := conn.Read(buf)

View File

@@ -1,3 +1,5 @@
// cmd/quic/main_test.go 包含用于约束 quic 行为的测试。
package main
import (

View File

@@ -1,3 +1,5 @@
# compose.override.yaml 把本地 Compose 覆盖项与偏生产形态的基础服务定义分开维护。
services:
mc-gateway:
build:

View File

@@ -1,3 +1,5 @@
# compose.yaml 定义偏生产形态的网关容器、数据卷、端口和避开代理干扰的健康检查。
services:
mc-gateway:
image: ${MC_GATEWAY_IMAGE:-ghcr.io/tursom/mc-gateway:latest}

View File

@@ -1,3 +1,5 @@
# config.example.toml 说明启动监听器和运行态默认值所需的静态网关配置字段。
# pid 文件
pid_file = "gateway.pid"

View File

@@ -0,0 +1,493 @@
# 插件开发工具链设计
本文定义插件开发工具链的功能需求和实现边界。目标是让插件作者从新建、开发、测试、打包到发布前检查都使用同一套 `gateway plugin` CLI而不是在每个示例插件里维护重复脚本。
本设计以 [plugin-system-design.md](plugin-system-design.md) 和 [plugin-implementation-plan.md](plugin-implementation-plan.md) 为上游约束。插件作者只维护一个 manifest source 文件,支持 `manifest.yaml``manifest.yml``manifest.toml``manifest.jsonc``manifest.json``.mcgp` 包内仍统一物化为 `manifest.json`。Go 代码中不再维护 `manifestJSON` 或等价重复元数据。
## 目标
- 提供 `gateway plugin init/build/test` 三个核心开发入口。
- 让示例插件和第三方插件使用同一套构建、打包、校验和测试流程。
- 支持 binary `.mcgp` 和 source `.mcgp`,并逐步替代示例插件内的 `build.sh``cmd/render-manifest` 等重复逻辑。
- 保持工具链 runtime-neutralGo plugin 是第一批实现目标,后续 `go-plugin-process``sandbox-process`、WASM 和 ingress service 通过 runtime adapter 扩展。
- 保证 CLI 产物可被 Admin/API 的服务端校验重复验证CLI 只是开发体验和预检工具,不是信任边界。
- 产物尽量稳定可复现:相同输入、相同 builder 和相同环境生成相同 zip 排序、权限和摘要。
## 非目标
- 不引入 `gateway plugin dev ...` 命名空间;开发命令直接扩展在 `gateway plugin` 下。
- 不恢复代码内 manifest 元数据。
- 不支持插件自定义构建脚本作为默认路径。
- 不把 source build 当成 runtime sandbox。
- 不在第一版支持远程插件市场、签名分发或自动升级。
- 不承诺 Go plugin 真正热卸载;本地调试仍遵守运行时限制。
## 设计决策
| 决策 | 结论 |
| --- | --- |
| CLI 命名 | 直接扩展 `gateway plugin init/build/test`,不新增 `dev` 子命名空间 |
| 元数据来源 | 插件目录只允许一个人工维护的 manifest source包内可信元数据统一为 canonical `manifest.json` |
| 打包入口 | `gateway plugin build` 同时承担 build 和 package不再要求插件目录自带 zip 脚本 |
| 示例插件 | `upstream-rewrite``mc-auth-proxy` 迁移到标准 CLI删除重复 `build.sh``render-manifest` 逻辑 |
| runtime 扩展 | CLI 通过 runtime build/test adapter 分发逻辑,命令名不随 runtime 改变 |
| 校验边界 | CLI 校验不能替代 gateway 服务端上传、构建、准入和 enable 校验 |
| source manifest | 源码目录中的 `manifest.yaml/yml/toml/jsonc/json` 是作者输入artifact 包内的 `manifest.json` 是构建时物化结果,不作为第二份人工维护数据 |
## 命令总览
第一版重点实现:
| 命令 | 用途 |
| --- | --- |
| `gateway plugin init <dir>` | 生成插件模板 |
| `gateway plugin build [dir]` | 构建并打包 binary/source `.mcgp` |
| `gateway plugin test [dir]` | 运行插件单元测试和 harness 测试 |
| `gateway plugin validate <path>` | 校验 manifest、源码目录或 `.mcgp` 包 |
| `gateway plugin inspect <artifact.mcgp>` | 查看包内 manifest 和摘要 |
| `gateway plugin compat <artifact.mcgp>` | 检查当前 gateway 对 artifact 的兼容性 |
现有 `gateway plugin source-build <source.mcgp> [out.mcgp]` 保留为兼容命令。后续可以由 `gateway plugin build --from-source <source.mcgp> --out <out.mcgp>` 覆盖同等能力,再把 `source-build` 标记为兼容别名。
所有面向 CI 的命令都应支持:
- `--json`:输出机器可读结果。
- `--quiet`:只输出错误或关键产物路径。
- `--out <path>`:指定产物或报告位置。
- 稳定退出码:参数错误、校验失败、构建失败和测试失败应可区分。
## 完整功能域
工具链最终需要覆盖从插件作者到生产运维的完整闭环。下表是功能需求清单,阶段表示推荐落地顺序,不代表命令只能在该阶段出现。
| 功能域 | 需要解决的问题 | 关键命令 |
| --- | --- | --- |
| 项目脚手架 | 快速生成可构建、可测试、manifest 正确的插件目录 | `init` |
| Manifest 编辑 | 发现字段错误、解释支持能力、避免人工维护环境字段 | `validate``manifest format``manifest explain``features` |
| 构建和打包 | 统一 binary/source `.mcgp` 产物,替代示例脚本 | `build``clean` |
| Source 构建复现 | 在本地或 CI 复现 gateway builder 行为 | `build --from-source` |
| 单元和契约测试 | 在真实上传前验证 SDK、extension point 和 fixture | `test``conformance` |
| 本地安装调试 | 把产物上传到开发 gateway启用、禁用、回滚和查看状态 | `upload``enable``disable``rollback``status` |
| 配置和 secret 预检 | 在启用前验证 config schema、secret ref、reload 兼容性 | `config validate``secret check``preflight` |
| 发布门禁 | 生成能进入 review/CI 的证据 | `preflight``self-test``benchmark` |
| 观测诊断 | 收集插件日志、事件、指标、trace 和诊断包 | `logs``events``metrics``diagnose` |
| 后台任务 | 开发和运维手动触发任务、查看执行状态 | `task list``task run``task cancel` |
| 数据和文件 | 查看 plugin_data/runtime files 配额、导出可迁移数据、GC | `data inspect/export/gc``files inspect/export/gc` |
| Promotion | 跨环境导入导出、diff、drift 和灾备演练 | `export``import``diff``drift``dr-drill` |
| 仓库和供应链 | 导入仓库候选、验证 SBOM/license/signature/advisory | `repo``sbom``sign``verify``advisory` |
| SDK 和契约治理 | 发布前检查 SDK/API/manifest/错误码兼容性 | `contract check``schema export``conformance` |
| Runtime 扩展 | 让新 runtime 复用同一套 init/build/test/validate 命令 | runtime adapter、`runtime features` |
### 命令分层
为了避免第一版实现过大,命令按层交付:
| 层级 | 阶段 | 命令 | 说明 |
| --- | --- | --- | --- |
| 0 | 已有能力 | `inspect``validate``compat``source-validate``source-build` | 当前 CLI 基线,后续保持兼容 |
| 1 | 阶段 1-3 | `init``build``test``features``manifest format/explain` | 插件作者日常开发闭环 |
| 2 | 阶段 4 | `upload``enable``disable``rollback``status``config validate``secret check` | 本地开发 gateway 和 Admin API 操作闭环 |
| 3 | 阶段 5 | `preflight``self-test``benchmark``review status``advisory scan` | 发布治理和准入证据 |
| 4 | 阶段 6 | `logs``events``metrics``diagnose``task``data``files``gc` | 运行诊断、后台任务、数据和资源治理 |
| 5 | 阶段 7-8 | `repo``sbom``sign``verify``contract``conformance``export/import/diff/drift/dr-drill` | 生态、供应链、跨环境发布和未来 runtime |
第一版不必一次实现所有命令,但设计上要避免把能力做进一次性脚本。每个命令都应能输出 JSON 报告,方便 CI 和 Admin API 复用。
## 开发工作流
工具链需要支持这些端到端流程。
### 新插件开发
```sh
gateway plugin init ./my-plugin --id my-plugin --template upstream-dialer --module example.com/my-plugin
cd ./my-plugin
gateway plugin validate .
gateway plugin test .
gateway plugin build . --type both
gateway plugin compat dist/my-plugin.mcgp
```
完成标准:
- 不需要手写 zip 命令。
- 不需要手写 `render-manifest`
- 不需要在 Go 代码中声明 manifest 元数据。
- 默认模板生成 `manifest.yaml`;如需其它格式可使用 `gateway plugin init --manifest-format yaml|toml|jsonc|json`
### 本地调试
```sh
gateway plugin build . --type binary
gateway plugin upload dist/my-plugin.mcgp --gateway http://127.0.0.1:8080
gateway plugin enable my-plugin --config testdata/config.json --profile dev
gateway plugin status my-plugin
gateway plugin logs my-plugin --tail 100
gateway plugin disable my-plugin
```
本地调试命令通过 Admin API 工作,不绕过服务端校验。需要认证时使用现有 Admin session/token 机制CLI 不保存 secret 明文。
### CI 发布检查
```sh
gateway plugin validate .
gateway plugin test . --profile unit,manifest,harness,protocol-smoke
gateway plugin build . --type both --json --out dist/build-report.json
gateway plugin compat dist/my-plugin.mcgp --json --out dist/compat-report.json
gateway plugin preflight dist/my-plugin.mcgp --config config/prod.json --profile prod --json
gateway plugin benchmark dist/my-plugin.mcgp --profile ci-contract --json
```
CI 报告必须能作为 review 证据保存,并包含 artifact sha256、source sha256、SDK/API 版本、runtime、extension points、config hash、测试 profile 和失败原因。
### Source 包复现
```sh
gateway plugin build . --type source
gateway plugin build --from-source dist/my-plugin-source.mcgp --out dist/my-plugin-rebuilt.mcgp
gateway plugin compat dist/my-plugin-rebuilt.mcgp
```
该流程用于验证源码包能被受控 builder 重建,且构建失败不会影响 active artifact。
### 跨环境发布
```sh
gateway plugin export my-plugin --profile staging --out promotion.json
gateway plugin diff promotion.json --target prod
gateway plugin import promotion.json --target prod --dry-run
gateway plugin drift --baseline promotion.json --target prod
```
promotion bundle 默认不包含 secret 明文、secret 密文和 runtime state。缺失 secret mapping、runtime 不兼容、advisory 命中或策略阻断时必须失败。
## `gateway plugin init`
`init` 负责生成一个可直接构建和测试的插件目录。
### 输入
推荐参数:
| 参数 | 说明 |
| --- | --- |
| `--id <id>` | 插件 ID必须满足 manifest 命名规则 |
| `--name <name>` | 展示名,默认由 ID 派生 |
| `--template <name>` | 模板名 |
| `--runtime <type>` | runtime 类型,默认 `go-plugin` |
| `--module <module>` | Go module pathGo runtime 模板必填或由目录推导 |
| `--extension <key>` | 目标 extension point |
第一批模板:
| 模板 | runtime | extension point | 说明 |
| --- | --- | --- | --- |
| `upstream-dialer` | `go-plugin` | `upstream.connect/v1` | 最小 dialer mode 模板 |
| `protocol-proxy` | `go-plugin` | `upstream.connect/v1` | 最小 Minecraft protocol-proxy 模板 |
| `empty-go` | `go-plugin` | 无默认 handler | 用于自定义实验 |
预留模板:
| 模板 | runtime | 说明 |
| --- | --- | --- |
| `wasm-rule` | `wasm` | 未来 rule/config validate 类轻量插件 |
| `sandbox-process` | `sandbox-process` | 未来隔离进程插件 |
| `ingress-service` | `sandbox-process` 或专用 runtime | 未来入口服务插件 |
### 输出目录
Go plugin 模板应至少生成:
- `manifest.yaml`(默认;也支持 `manifest.yml``manifest.toml``manifest.jsonc``manifest.json`
- `go.mod`
- `main.go`
- `main_test.go`
- `README.md`
- `testdata/config.json`
- `testdata/fixtures/`,按模板放置 harness 输入
生成的 manifest source 只包含作者应该维护的字段。`go_version``go_os``go_arch` 等环境相关字段可以为空或使用文档化占位;`build` 时再物化到 artifact manifest。
## `gateway plugin build`
`build` 是统一构建和打包入口。
### 常用模式
| 命令 | 结果 |
| --- | --- |
| `gateway plugin build .` | 默认生成 binary `.mcgp` |
| `gateway plugin build . --type binary` | 生成 binary `.mcgp` |
| `gateway plugin build . --type source` | 生成 source `.mcgp` |
| `gateway plugin build . --type both` | 同时生成 binary 和 source `.mcgp` |
| `gateway plugin build --from-source source.mcgp --out built.mcgp` | 使用 gateway builder 从 source 包生成 binary 包 |
当源码目录内存在多个 `manifest.*` 文件,`build` 必须通过 `--manifest <path>` 显式选择源文件;同一规则也适用于 `test``validate``preflight``self-test``benchmark``manifest format`
推荐默认输出:
- `dist/<plugin-id>.mcgp`
- `dist/<plugin-id>-source.mcgp`
- `dist/<plugin-id>-built.mcgp`
- `dist/build-report.json`
### Manifest 物化规则
源码目录中只能存在一个 manifest source 文件。`build` 读取 `manifest.yaml/yml/toml/jsonc/json` 后在内存中生成 artifact manifest并写入 `.mcgp` 包内的 canonical `manifest.json`
- `artifact_type``--type` 写为 `binary``source`
- binary 包写入 `runtime.entry=plugin.so`
- Go plugin binary 包写入实际 `go_version``go_os``go_arch`
- source 包写入 `build.type=go``build.entry``build.output``build.tags` 和 vendor 策略。
- 构建 provenance、module summary、artifact sha256 等写入 build report 或服务端 build record不要求回写源码目录的 manifest source。
这保证源码仓库里没有第二份需要维护的 manifest也避免 manifest source 与 Go 代码常量不一致。
如果目录中同时存在多个 `manifest.*` 文件CLI 必须失败并要求传入 `--manifest <path>` 显式选择,避免不同格式的 manifest 分叉。`gateway plugin manifest format --canonical-json --type binary|source` 可查看最终写入对应 `.mcgp` 的规范 JSON不传 `--type` 时使用 manifest source 中的 `artifact_type`,缺省按 binary 处理。`--write` 对 YAML/TOML/JSONC 必须保留注释,无法保留时不能覆盖源文件。
### Go Plugin Adapter
第一版 `go-plugin` build adapter 负责:
1. 读取并校验唯一 manifest source或通过 `--manifest` 指定的 manifest source。
2. 运行 `go test ./...`,除非传入 `--skip-tests`
3. 用固定命令构建 `plugin.so``go build -buildmode=plugin -trimpath -buildvcs=false`
4.`go tool nm` 校验 `Plugin` 符号。
5. 生成稳定 zip固定 entry 排序、权限、时间戳策略和路径分隔符。
6. 生成 source `.mcgp` 时只包含允许的源码、`go.mod`、可选 `go.sum/vendor`、README、LICENSE、SBOM 和测试 fixture。
7. 输出 artifact sha256、source sha256、Go/API/SDK 版本和 ABI fingerprint。
第一版不执行包内脚本。未来如果需要复杂构建,应通过受控 builder profile 或外部 CI而不是让插件包携带任意 shell 脚本。
### Runtime Adapter 预留
CLI 内部应抽象 build adapter
```go
type PluginBuildAdapter interface {
RuntimeType() string
ValidateSource(ctx context.Context, req BuildCLIRequest) error
Build(ctx context.Context, req BuildCLIRequest) (BuildCLIResult, error)
PackageSource(ctx context.Context, req BuildCLIRequest) (BuildCLIResult, error)
}
```
预留 runtime 行为:
| runtime | build 产物 | source 包 | 测试方式 |
| --- | --- | --- | --- |
| `go-plugin` | `plugin.so` | Go module source | Go test + extension harness |
| `go-plugin-process` | `plugin.so` 或 host bundle | Go module source | 子进程 host harness |
| `sandbox-process` | executable 或 bundle | 受控源码/二进制 bundle | control RPC harness |
| `wasm` | `plugin.wasm` | WASM source/bundle | WASM host ABI harness |
| `builtin` | 无外部 artifact | 不适用 | gateway 内部测试 |
命令层不应写死 Go plugin 细节。新增 runtime 时只新增 adapter、manifest 校验和 harness不新增一套用户命令。
## `gateway plugin test`
`test` 负责把插件作者的本地测试和 gateway extension contract 连接起来。
### 测试 profile
| Profile | 说明 |
| --- | --- |
| `unit` | 运行插件目录原生测试,例如 `go test ./...` |
| `manifest` | 校验 manifest schema、命名、runtime、extension point 和 config schema |
| `harness` | 运行 extension point fixture |
| `protocol-smoke` | 运行 Minecraft handshake/login smoke fixture |
| `conformance` | 运行当前 gateway 公开契约兼容测试 |
常用命令:
| 命令 | 结果 |
| --- | --- |
| `gateway plugin test .` | 运行模板默认 profile |
| `gateway plugin test . --profile unit,harness` | 运行指定 profile |
| `gateway plugin test . --config testdata/config.json` | 使用指定配置测试 |
| `gateway plugin test . --fixture testdata/fixtures/login-reject.json` | 使用指定 fixture |
| `gateway plugin test dist/plugin.mcgp --profile compat` | 对已打包 artifact 做兼容测试 |
### Harness 范围
第一版 harness 覆盖:
- `upstream.connect/v1` dialer mode匹配 host、返回 `api.ErrPass`、返回自管 conn、错误传播。
- `upstream.connect/v1` protocol-proxy modeinitial data replay、handshake/login packet fixture、disconnect/kick 响应、读写关闭。
- config`ReloadConfig()` 成功、失败、默认值和 schema 校验。
- lifecycle`Init()``Destroy()` 幂等、handler timeout、panic recover。
未来 runtime harness
- `go-plugin-process`:通过 plugin-host 启动插件,验证 drain-only、crash loop 和 control channel。
- `sandbox-process`:验证 capability enforcement、secret handle、filesystem/network policy。
- `wasm`:验证 host ABI、memory/time limit、无授权文件和网络访问。
- `ingress.service/v1`:验证 listener 由 gateway 创建、端口冲突和 disable drain。
## `gateway plugin validate`
`validate` 应支持三类输入:
- manifest source 文件:`manifest.yaml``manifest.yml``manifest.toml``manifest.jsonc``manifest.json`
- 插件源码目录
- `.mcgp` artifact
校验内容:
- manifest schema 和必填字段。
- runtime type、runtime entry、build entry。
- extension point key、type 和 mode。
- config schema JSON。
- secret、event、metric、background task、external dependency、data store 和 file store 命名。
- binary/source 包结构、zip slip、大小限制和允许文件。
- 当前 gateway feature support。
对于源码目录,`validate` 不能执行插件代码最多做静态文件、manifest 和包结构检查。需要运行代码的检查放在 `test``build`
## 本地 Admin 操作命令
阶段 4 后CLI 应能操作开发或测试环境的 Admin API形成不依赖页面的调试闭环。
| 命令 | 职责 |
| --- | --- |
| `gateway plugin upload <artifact.mcgp>` | 上传 artifact/source package返回 artifact ID、sha256 和校验摘要 |
| `gateway plugin status [plugin-id]` | 展示 desired/runtime state、active/desired/loaded artifact、recent error 和 restart required |
| `gateway plugin enable <plugin-id>` | 设置 desired enabled支持 `--artifact``--config``--profile``--priority` |
| `gateway plugin disable <plugin-id>` | 设置 desired disabledprotocol-proxy 连接按策略 drain 或 force close |
| `gateway plugin delete <plugin-id>` | 删除 desired state 或 artifact支持保留/删除数据选项 |
| `gateway plugin rollback <plugin-id>` | 回滚 artifact 或 config snapshot并重新执行当前基础门禁 |
| `gateway plugin config validate <plugin-id>` | 校验 config JSON、schema、secret ref 和 `ReloadConfig()` dry-run |
| `gateway plugin secret check <plugin-id>` | 检查 manifest 必需 secret、secret ref、版本和 reload/rotation 状态 |
这些命令必须通过 Admin API 执行并复用服务端权限、审计和错误码。CLI 不直接写 SQLite不直接操作 artifact store也不能绕过上传时的 zip/manifest 校验。
## 发布治理命令
阶段 5 后CLI 需要生成和读取生产准入证据。
| 命令 | 职责 |
| --- | --- |
| `gateway plugin preflight` | 运行 config、secret、feature、runtime limits、scope/rollout、conflict 和 Minecraft capability 检查 |
| `gateway plugin self-test` | 运行插件实现的 quick/protocol-smoke/integration profile保存脱敏证据 |
| `gateway plugin benchmark` | 记录或执行 benchmark profile输出 P95/P99、error rate、capacity 和 baseline diff |
| `gateway plugin review status` | 查看当前 artifact/config/scope/risk/policy hash 是否已有有效 review |
| `gateway plugin advisory scan` | 按 artifact sha256、plugin/version、SBOM dependency 或 source metadata 扫描安全公告 |
发布治理命令的 JSON 报告必须包含稳定 `code``severity``message``evidence_id` 和相关 hash不能要求 CI 解析人类可读文本。
## 观测和运维命令
阶段 6 后CLI 应覆盖插件出问题时的定位、证据导出和资源清理。
| 命令 | 职责 |
| --- | --- |
| `gateway plugin logs <plugin-id>` | 查看插件日志摘要,支持 tail、时间范围、trace ID 和脱敏 |
| `gateway plugin events <plugin-id>` | 查看插件业务事件、drop/dead-letter 摘要和 replay/drop 操作 |
| `gateway plugin metrics <plugin-id>` | 查看 handler calls、duration、panic、timeout、active proxy connections 和 custom metrics |
| `gateway plugin diagnose <plugin-id>` | 生成诊断包,包含 manifest、state、recent logs/events/metrics/build summary不含 secret 明文 |
| `gateway plugin task list/run/cancel <plugin-id>` | 查看、手动触发或取消 background task |
| `gateway plugin data inspect/export/gc <plugin-id>` | 查看 plugin_data schema/data class/quota导出可迁移数据执行 dry-run 或清理 |
| `gateway plugin files inspect/export/gc <plugin-id>` | 查看 runtime files/resources/cache/tmp/log/diagnostic 用量和 GC candidate |
| `gateway plugin gc --dry-run` | 汇总 artifact、build log、diagnostic、plugin_data 和 runtime files 的可清理对象 |
所有清理命令默认 dry-run实际删除必须显式传入确认参数并写审计。数据导出只允许 manifest 声明 `exportable=true` 且调用者有权限的数据。
## 仓库、供应链和签名命令
阶段 8 的分发能力不能绕过本地 review 和 enable 流程。
| 命令 | 职责 |
| --- | --- |
| `gateway plugin repo list/search/show` | 查看 official/internal/file/url repository 中的候选版本 |
| `gateway plugin repo import` | 下载或导入候选 artifact 到本地 store只生成 local artifact不自动启用 |
| `gateway plugin sbom generate/verify` | 生成或验证 SBOM供 advisory/license 策略使用 |
| `gateway plugin sign` | 对 artifact 或 promotion bundle 签名,未来能力 |
| `gateway plugin verify` | 验证 signature、sha256、SBOM、license 和 provenance |
| `gateway plugin advisory import/scan/ack` | 导入安全公告、重新扫描本地 artifact、记录 mitigation/ack |
仓库删除、远端更新或签名失败都不能自动改变本地 active artifact。repository import 之后仍要走 validate、compat、preflight、review 和 enable。
## 契约和 SDK 命令
插件系统公开 API 后CLI 还要服务 gateway release 过程。
| 命令 | 职责 |
| --- | --- |
| `gateway plugin features` | 输出当前 gateway 支持的 runtime、extension point、manifest field、feature key 和版本 |
| `gateway plugin schema export` | 导出 manifest JSON schema、config UI hint schema 和 extension fixture schema |
| `gateway plugin contract check` | 对比上一 release 的 SDK/API/manifest/error code/CLI JSON 输出兼容性 |
| `gateway plugin conformance` | 构建示例插件,运行 source/binary fixture 和 Admin/CLI golden test |
`features` 输出必须和 Admin API 使用同一契约。`contract check``conformance` 失败应被视为 gateway release 风险,不是普通文档错误。
## Runtime 扩展命令
新增 runtime 不应增加一套平行 CLI。`init/build/test/validate/compat/preflight` 必须根据 `manifest.runtime.type` 选择 adapter。
| runtime | 额外 CLI 需求 |
| --- | --- |
| `go-plugin-process` | `test` 能启动 plugin-host harness`preflight` 检查 migration mode、safe point、drain-only/fd-live 声明 |
| `sandbox-process` | `validate/preflight` 检查 capability、secret handle、filesystem/network/env/cpu/memory policy`test` 验证 control RPC 和 crash loop |
| `wasm` | `build` 生成 `plugin.wasm``test` 使用 WASM host ABI`preflight` 检查 memory/time/no file/no network |
| `ingress.service/v1` | `preflight` 检查 listener ownership、port conflict、TLS/secret refs 和 disable drain |
| build-time instrumentation | 不进入 runtime plugin enable/disableCLI 只提供 manifest/provenance/conformance/benchmark/smoke 证据 |
如果目标 gateway 不支持某 runtime`compat``preflight` 必须返回明确的 blocking code而不是降级为 Go plugin 尝试加载。
## 发布前检查
发布前推荐流程:
1. `gateway plugin validate .`
2. `gateway plugin test . --profile unit,manifest,harness`
3. `gateway plugin build . --type both`
4. `gateway plugin validate dist/<plugin-id>.mcgp`
5. `gateway plugin compat dist/<plugin-id>.mcgp`
6. 可选:`gateway plugin build --from-source dist/<plugin-id>-source.mcgp --out dist/<plugin-id>-rebuilt.mcgp`
7. 可选:`gateway plugin test dist/<plugin-id>.mcgp --profile conformance`
CI 产物应至少保存:
- binary `.mcgp`
- source `.mcgp`
- build report JSON
- test report JSON
- artifact sha256 和 source sha256
## 示例插件迁移
`examples/plugins/upstream-rewrite``examples/plugins/mc-auth-proxy` 迁移目标:
- README 使用 `gateway plugin build . --type both`
- README 使用 `gateway plugin test .`
- 删除或降级 `build.sh` 为兼容包装;最终不再作为主路径。
- 删除 `cmd/render-manifest`,由 CLI 根据源码 manifest source 生成 artifact manifest。
- 示例插件的测试 fixture 进入 `testdata/fixtures/`
- 示例插件进入 conformance suite构建失败视为插件 API 回归。
迁移时必须保留现有 `.mcgp` 格式binary 包仍包含 `manifest.json``plugin.so`source 包仍包含 `manifest.json``go.mod`、build entry 和源码。
## 实现顺序
建议按以下顺序实现:
1. 增加 `gateway plugin init`,生成 `upstream-dialer``protocol-proxy` Go 模板。
2. 增加 `gateway plugin build` 的 Go plugin binary/source 打包能力,复用现有 artifact 校验逻辑。
3.`gateway plugin build` 替换示例插件 `build.sh``cmd/render-manifest` 主路径。
4. 增加 `gateway plugin test` 的 unit、manifest 和 upstream harness profile。
5.`source-build` 能力收敛为 `build --from-source`,保留兼容别名。
6. 增加 runtime build/test adapter 接口,为 `go-plugin-process``sandbox-process` 和 WASM 实现预留扩展点。
7. 增加 JSON report、conformance profile 和 CI golden 输出。
每一步结束时,现有 `inspect/validate/compat/source-validate/source-build` 不能回归。
## 验收标准
- 新建 `upstream-dialer` 模板后,不手写额外脚本即可 build/test/validate。
- 新建 `protocol-proxy` 模板后,能跑通 Minecraft handshake/login smoke fixture。
- `upstream-rewrite``mc-auth-proxy` 示例插件使用标准 CLI 生成 binary/source `.mcgp`
- 生成的 `.mcgp` 能通过现有上传和服务端校验。
- manifest source 与 Go 代码不重复维护插件元数据。
- Go plugin adapter 之外的 runtime 可以通过 adapter 注册进入同一套 `init/build/test` 命令。
- CLI 失败输出能定位到字段、文件或 fixture而不是只返回通用错误。

View File

@@ -2,6 +2,8 @@
本文以 [plugin-system-design.md](plugin-system-design.md) 作为最终目标设计文档,把插件系统拆分成多个可上线的实现阶段。每个阶段都必须在结束时保持 gateway 当前可用:可以启动、可以回滚、可以排障,且不会要求后续阶段补齐后才能恢复基本能力。
插件开发工具链作为跨阶段交付项单独设计,见 [plugin-development-toolchain-design.md](plugin-development-toolchain-design.md)。工具链主入口为 `gateway plugin init/build/test`,并需要从第一批 Go plugin 示例开始预留未来 runtime adapter。
## 拆分原则
- 以可用的纵向切片拆分而不是按数据库、API、UI、SDK 等横向模块拆分。
@@ -37,6 +39,7 @@
| Minecraft capability manifest、protocol smoke fixture | 2 | 支撑管理页展示和后续发布门禁 |
| source `.mcgp`、builder、构建 provenance | 3 | 源码包构建成 `plugin.so` 后复用阶段 1/2 加载路径 |
| builder 隔离、Go/module/ABI 记录、source/build log GC | 3 | 构建失败不影响 active artifact |
| `gateway plugin init/build/test` 开发工具链 | 1-3后续扩展 | 阶段 1/2 提供 Go plugin 模板和 harness阶段 3 收敛 source/binary 打包;后续 runtime 通过 adapter 接入 |
| Admin 页面基础管理闭环 | 4 | 上传、构建状态、加载、启用、禁用、删除、回滚 |
| 配置 schema、配置快照、配置迁移入口 | 4 | 错误配置不切换 active artifact |
| SecretStore、secret version、reload/rotation 基础 | 4 | secret 不在页面、日志、审计中明文展示 |

View File

@@ -20,7 +20,7 @@
- 第一版继续使用 Go `-buildmode=plugin` 的进程内 `.so` 插件。
- 尽量支持热加载:兼容且未加载过的插件可以不重启加载并启用。
- 明确热卸载限制Go plugin 不能真正从进程中卸载,只能逻辑禁用。
- 通过 `manifest.json` 记录插件 ID、版本、目标平台、构建 Go 版本、SDK/API 版本、声明的 extension point 和配置 schema。
- 通过 manifest source 记录插件 ID、版本、目标平台、构建 Go 版本、SDK/API 版本、声明的 extension point 和配置 schema;源码目录只维护一份 `manifest.yaml/yml/toml/jsonc/json``.mcgp` 包内统一物化为 canonical `manifest.json`
- 插件能力采用 Extension Point 模型Hook 是其中一种;第一版先落 `upstream.connect/v1`
- `upstream.connect/v1` 必须支持插件返回自管 `net.Conn`,用于实现完整 stream endpoint/protocol proxy。
- MC 正版/三方登录、身份映射、forwarding 和登录后的协议处理属于插件业务逻辑,不由 gateway core 拼装。
@@ -33,7 +33,7 @@
| 决策/功能点 | 当前设计结论 | 阶段 | 主要章节 |
| --- | --- | --- | --- |
| 插件信任模型 | 第一版只支持可信 native 插件,不把普通插件当不可信代码运行 | 第一版 | Runtime 与权限声明、安全与运维约束 |
| 插件包格式 | 管理页统一上传 `.mcgp` zip 包,源码/二进制由 `manifest.json``artifact_type` 决定 | 第一版 | 包格式、源码包构建 |
| 插件包格式 | 管理页统一上传 `.mcgp` zip 包,源码/二进制由包内 canonical `manifest.json``artifact_type` 决定 | 第一版 | 包格式、源码包构建 |
| 自定义扩展名 | 不新增源码包扩展名;同一 `.mcgp` 格式承载 binary/source | 第一版 | 包格式 |
| Go plugin ABI | 仅使用 `.mcgp` 内的 `manifest.json` 表示元数据,并记录 Go/API/SDK/ABI fingerprint | 第一版 | Manifest 元数据、Go Plugin ABI Fingerprint |
| Admin 管理 | 上传、构建、加载、启用、禁用、切换版本、删除、回滚和审计都进入 Admin/SQLite | 第一版 | 数据模型、生命周期、Admin API |
@@ -67,9 +67,9 @@
| 管理页上传、构建、加载、启用/禁用、删除和切换版本 | 全部进入 Admin/SQLite 生命周期,删除已加载 native artifact 后提示重启彻底清理 | 数据模型、生命周期、Admin API、Admin 页面 |
| 热加载尽量支持,热卸载承认 Go plugin 限制 | 兼容且未加载过 artifact 可热加载;已加载 Go plugin 只能逻辑禁用,不能真正卸载 | 生命周期、热加载和热卸载、Runbook |
| 多进程模型实现进程级热卸载 | 预留 `go-plugin-process` runtime主进程只做管理和 fd 编排,子进程负责数据面;通过退出子进程回收 Go plugin | Go Plugin Process Runtime、第一版默认策略 |
| 插件包不新增源码扩展名 | 统一 `.mcgp` zip源码/二进制由 `manifest.json.artifact_type` 声明 | 插件包格式 |
| 插件包不新增源码扩展名 | 统一 `.mcgp` zip源码/二进制由包内 `manifest.json.artifact_type` 声明 | 插件包格式 |
| 支持源码包和构建环境设计 | source `.mcgp` 经受控 builder 生成 `plugin.so`;开发 local-process生产推荐 container builder 或外部 CI | 源码包构建环境、供应链元数据 |
| Manifest 元数据稳定并记录 Go 构建信息 | `manifest.json`唯一元数据来源,记录 Go/API/SDK/ABI fingerprint、builder 和 provenance | Manifest 元数据、Go Plugin ABI Fingerprint |
| Manifest 元数据稳定并记录 Go 构建信息 | 作者只维护一个 manifest source包内 canonical `manifest.json`服务端唯一可信元数据来源,记录 Go/API/SDK/ABI fingerprint、builder 和 provenance | Manifest 元数据、Go Plugin ABI Fingerprint |
| Hook 之外的插件技术方案 | 统一 Extension Point 模型,覆盖 hook、middleware、provider、event subscriber、rule/policymock/mixin/monkey patch 不作为生产机制 | Extension Point 设计、Mock 和 Mixin 的定位 |
| 沙箱功能要有未来路线 | 第一版不提供沙箱;预留 sandbox-process、WASM、capability enforcement、stream relay 和 egress 策略 | Sandbox Runtime、Runtime Adapter、第一版默认策略 |
| Alibaba 非侵入 Go 注入的参考价值 | 作为官方/组织 build-time instrumentation 未来能力,不作为普通运行时插件或热加载机制 | Build-Time Instrumentation |
@@ -285,7 +285,7 @@
## 插件包格式
管理页上传的插件包统一使用 `.mcgp`,本质是 zip 包。包内内容由 `manifest.json` 决定,不通过扩展名区分源码包和二进制包。
管理页上传的插件包统一使用 `.mcgp`,本质是 zip 包。包内内容由 canonical `manifest.json` 决定,不通过扩展名区分源码包和二进制包。开发目录可以维护 `manifest.yaml``manifest.yml``manifest.toml``manifest.jsonc``manifest.json`,但构建进入 `.mcgp` 时必须统一物化为根目录 `manifest.json`
包类型由两个字段表达:
@@ -313,7 +313,7 @@ upstream-rewrite.mcgp
README.md # 可选
```
`manifest.json` 是加载前可读取的元数据,用于避免必须执行插件代码才能知道基础信息。上传阶段只解析 zip 和 manifest不执行插件代码。
包内 `manifest.json` 是加载前可读取的可信元数据,用于避免必须执行插件代码才能知道基础信息。上传阶段只解析 zip 和 manifest不执行插件代码。
二进制包 manifest 示例:
@@ -459,7 +459,7 @@ upstream-rewrite.mcgp
}
```
二进制包上传后可以直接登记为 artifact。源码包上传后必须先进入 builder构建出 `plugin.so` 后再登记为 artifact。加载阶段始终只加载最终产物 `plugin.so`;元数据以已校验入库的 `manifest.json` 为准。
二进制包上传后可以直接登记为 artifact。源码包上传后必须先进入 builder构建出 `plugin.so` 后再登记为 artifact。加载阶段始终只加载最终产物 `plugin.so`;元数据以已校验入库的包内 `manifest.json` 为准。
开发环境可以允许直接上传 raw `.so`,但生产推荐只接受 `.mcgp`。直接上传 `.so` 时,加载前只能展示文件名、大小和 sha256生产路径仍应使用 `.mcgp` 提供 `manifest.json`
@@ -2009,7 +2009,7 @@ index 规则:
## Manifest 元数据
插件包的元数据只来自 `.mcgp` 根目录的 `manifest.json`。上传、准入、构建、兼容性检查和 Admin 展示都必须使用这份静态 manifestgateway 不通过执行插件代码读取元数据。
插件包的元数据只来自 `.mcgp` 根目录的 canonical `manifest.json`。上传、准入、构建、兼容性检查和 Admin 展示都必须使用这份静态 manifestgateway 不通过执行插件代码读取元数据。源码目录可以使用 YAML、TOML、JSONC 或 JSON 作为唯一 manifest source但进入 `.mcgp` 前必须规范化为 `manifest.json`
Go plugin 只需要导出一个 factory 符号:
@@ -6370,52 +6370,54 @@ type Gateway interface {
- `plugin/api` 稳定 API 文档。
- `examples/plugins/upstream-rewrite` 最小模板。
- `examples/plugins/mc-auth-proxy` protocol-proxy 模板。
- manifest JSON schema。
- 构建脚本模板
- `.mcgp` 打包脚本。
- manifest source 多格式解析和 canonical JSON schema。
- 统一的 `gateway plugin init/build/test` 开发工具链
详细工具链设计见 [plugin-development-toolchain-design.md](plugin-development-toolchain-design.md)。工具链必须继续遵守 manifest-only 元数据约束:插件作者只维护一个 manifest source 文件Go 代码中不再保存 `manifestJSON` 或等价重复元数据;`.mcgp` 包内仍以 canonical `manifest.json` 作为服务端可信边界。
### CLI 工具
建议提供 `mc-gateway plugin` 子命令,降低插件开发和运维成本。
建议提供 `gateway plugin` 子命令,降低插件开发和运维成本。开发入口直接扩展在 `gateway plugin init/build/test` 下,不新增 `dev` 子命名空间。
候选命令:
| 命令 | 说明 |
| --- | --- |
| `mc-gateway plugin init` | 生成插件模板 |
| `mc-gateway plugin validate manifest.json` | 校验 manifest schema、命名、capabilities 和 extension point |
| `mc-gateway plugin package --type source` | 打包 source `.mcgp` |
| `mc-gateway plugin package --type binary` | 打包 binary `.mcgp` |
| `mc-gateway plugin inspect plugin.mcgp` | 查看 manifest、supply chain、sha256、Go/API 版本 |
| `mc-gateway plugin compat plugin.mcgp` | 检查当前 gateway 是否可能加载该插件 |
| `mc-gateway plugin features` | 查看当前 gateway 支持的 feature key 和版本 |
| `mc-gateway plugin build` | 使用匹配 builder 本地构建 plugin.so |
| `mc-gateway plugin test` | 运行插件 harness 测试 |
| `mc-gateway plugin preflight` | 对插件包或已安装插件执行通用预检和插件 Preflight |
| `mc-gateway plugin self-test` | 运行 quick/protocol-smoke/integration 自测 profile |
| `mc-gateway plugin benchmark` | 运行插件 benchmark、soak 或 regression profile |
| `mc-gateway plugin contract check` | 校验契约文件和上一 release 的兼容性 |
| `mc-gateway plugin conformance` | 运行插件契约 conformance suite |
| `mc-gateway plugin export` | 从 Admin API 导出 promotion bundle |
| `mc-gateway plugin import` | 上传并校验 promotion bundle |
| `mc-gateway plugin diff` | 对比 bundle、目标环境和当前 desired state |
| `mc-gateway plugin drift` | 查看当前环境相对基线的漂移状态 |
| `mc-gateway plugin dr-drill` | 触发或查看灾备演练 |
| `mc-gateway plugin data inspect <plugin>` | 查看 plugin_data schema、data class、大小、配额和 GC candidate |
| `mc-gateway plugin data export <plugin>` | 导出允许迁移的数据,受 data_class 和权限控制 |
| `mc-gateway plugin data gc <plugin>` | 按 retention 清理过期或可丢弃 plugin_data |
| `mc-gateway plugin sbom` | 生成或校验 SBOM未来能力 |
| `mc-gateway plugin sign` | 签名插件包,未来能力 |
| `gateway plugin init` | 生成插件模板 |
| `gateway plugin validate <path>` | 校验 manifest、源码目录或 `.mcgp` |
| `gateway plugin build --type source` | 打包 source `.mcgp` |
| `gateway plugin build --type binary` | 构建并打包 binary `.mcgp` |
| `gateway plugin build --type both` | 同时生成 source/binary `.mcgp` |
| `gateway plugin build --from-source` | 从 source `.mcgp` 生成 binary `.mcgp`,逐步替代 `source-build` 主路径 |
| `gateway plugin test` | 运行插件 unit、manifest、harness 或 conformance profile |
| `gateway plugin inspect plugin.mcgp` | 查看 manifest、supply chain、sha256、Go/API 版本 |
| `gateway plugin compat plugin.mcgp` | 检查当前 gateway 是否可能加载该插件 |
| `gateway plugin features` | 查看当前 gateway 支持的 feature key 和版本 |
| `gateway plugin preflight` | 对插件包或已安装插件执行通用预检和插件 Preflight |
| `gateway plugin self-test` | 运行 quick/protocol-smoke/integration 自测 profile |
| `gateway plugin benchmark` | 运行插件 benchmark、soak 或 regression profile |
| `gateway plugin contract check` | 校验契约文件和上一 release 的兼容性 |
| `gateway plugin conformance` | 运行插件契约 conformance suite |
| `gateway plugin export` | 从 Admin API 导出 promotion bundle |
| `gateway plugin import` | 上传并校验 promotion bundle |
| `gateway plugin diff` | 对比 bundle、目标环境和当前 desired state |
| `gateway plugin drift` | 查看当前环境相对基线的漂移状态 |
| `gateway plugin dr-drill` | 触发或查看灾备演练 |
| `gateway plugin data inspect <plugin>` | 查看 plugin_data schema、data class、大小、配额和 GC candidate |
| `gateway plugin data export <plugin>` | 导出允许迁移的数据,受 data_class 和权限控制 |
| `gateway plugin data gc <plugin>` | 按 retention 清理过期或可丢弃 plugin_data |
| `gateway plugin sbom` | 生成或校验 SBOM,未来能力 |
| `gateway plugin sign` | 签名插件包,未来能力 |
CLI 规则:
- CLI 校验不能替代服务端校验,服务端必须重复做安全校验。
- package 命令必须生成稳定 zip避免无意义 sha256 变化。
- build 命令必须生成稳定 zip避免无意义 sha256 变化。
- inspect 命令不能执行插件代码。
- compat 命令只能做 preflight必须检查 required/optional features但不能保证 `plugin.Open` 一定成功。
- features 命令输出必须和 Admin `/plugins/features` API 使用同一契约。
- diff、drift、export 和 import 必须使用同一 canonical hash 与脱敏 diff 实现。
- build 命令应默认使用与 gateway release 匹配的 builder image。
- build 命令应默认使用与 gateway release 匹配的 builder image;本地 Go plugin adapter 可以先使用当前 Go toolchain
- data inspect 默认只显示摘要,不导出 value。
- data export 必须经过 Admin API 权限检查,且只能导出 manifest 声明 `exportable=true` 的数据。
- data gc 必须支持 dry-run先展示将清理的 data_class、key 数量和总大小。
@@ -6425,9 +6427,9 @@ CLI 规则:
本地开发流程:
1. 从示例复制插件目录。
2. 编写 `manifest.json`
2. 编写唯一 manifest source默认是 `manifest.yaml`
3. 使用与 gateway 匹配的 Go toolchain。
4. 运行示例脚本构建 `.mcgp`
4. 运行 `gateway plugin build` 构建 `.mcgp`
5. 通过 Admin 上传。
6. 查看 ABI 校验结果、构建日志、加载状态和运行错误。
@@ -7130,7 +7132,7 @@ API 错误响应应包含稳定错误码,便于管理页和 CLI 处理:
1. 查看 build log excerpt 和 builder image。
2. 确认 Go version、GOOS/GOARCH、CGO 和 build tags。
3. 检查 GOPROXY/vendor/private dependency 配置。
4. 使用 CLI 在本地或 CI 复现 `mc-gateway plugin build`
4. 使用 CLI 在本地或 CI 复现 `gateway plugin build`
5. 修正源码包后重新上传,或 retry 同一 build job。
构建失败不应改变 active artifact。
@@ -7202,10 +7204,12 @@ API 错误响应应包含稳定错误码,便于管理页和 CLI 处理:
examples/plugins/upstream-rewrite/
go.mod
main.go
main_test.go
manifest.json
README.md
build.sh
package-source.sh
testdata/
config.json
fixtures/
```
示例能力:
@@ -7231,10 +7235,12 @@ examples/plugins/upstream-rewrite/
examples/plugins/mc-auth-proxy/
go.mod
main.go
main_test.go
manifest.json
README.md
build.sh
package-source.sh
testdata/
config.json
fixtures/
```
示例能力:
@@ -7263,10 +7269,12 @@ examples/plugins/mc-auth-proxy/
examples/plugins/mc-status-motd/
go.mod
main.go
main_test.go
manifest.json
README.md
build.sh
package-source.sh
testdata/
config.json
fixtures/
```
示例能力:
@@ -7346,7 +7354,7 @@ examples/plugins/mc-status-motd/
- 定义 `.mcgp` 静态校验规则、大小限制和 zip slip 防护。
- 定义插件 ID、handler ID、task ID、secret name 和 extension point 命名规范。
- 支持 `artifact_type=binary/source``runtime.type=go-plugin`
- 确认插件只需要导出 `Plugin` factory元数据只来自 `manifest.json`
- 确认插件只需要导出 `Plugin` factory包内元数据只来自 canonical `manifest.json`
- 定义 Go plugin ABI fingerprint schema、计算规则、compat diff 和开发模式 override 语义。
-`plugin/api` 中补齐 `APIVersion`、extension point metadata、`ErrPass` 等。
- 把 upstream hook 收敛为 request struct。
@@ -7662,7 +7670,7 @@ examples/plugins/mc-status-motd/
- 插件测试 harness 能覆盖 dialer mode 和 protocol-proxy mode。
- 测试矩阵覆盖包格式、ABI、生命周期、配置、extension point、入口传输、上游协议、protocol-proxy、治理、secret、artifact 和 Admin 权限。
- 运维 Runbook 覆盖连接失败、启用失败、构建失败、secret 泄漏怀疑、磁盘占用过高和多实例部分失败。
- CLI 工具至少覆盖 manifest validate、package、inspect、compat、test、promotion export/import/diff、drift 和 dr-drill 的设计。
- CLI 工具至少覆盖 manifest validate、build/package、inspect、compat、test、promotion export/import/diff、drift 和 dr-drill 的设计。
- CLI 工具覆盖 plugin_data inspect/export/gc且 data gc 支持 dry-run。
- Admin API 错误响应有稳定 code管理页和 CLI 不依赖错误字符串解析。
- 文档能明确区分第一版能力、预留 extension point 和未来 runtime。
@@ -7696,7 +7704,7 @@ examples/plugins/mc-status-motd/
| 契约文件 | 第一版手写维护 JSON schema/contract后续可从 Go 类型和 manifest schema 生成并做 diff 校验 |
| SDK 发布节奏 | gateway release 与 plugin SDK release 默认绑定SDK 使用 SemVergateway 记录支持范围 |
| conformance suite | release 前必须运行并产出报告;第一版可先作为 release gateCI 阻断按模块成熟度逐步打开 |
| CLI 形态 | 第一版作为 gateway 二进制的 `mc-gateway plugin` 子命令;独立 `mc-gateway-plugin` 作为未来分发形态 |
| CLI 形态 | 第一版作为 gateway 二进制的 `gateway plugin` 子命令;独立 `gateway-plugin` 作为未来分发形态 |
| 插件服务启动模式 | 第一版固定 `in-process`Admin 可预留 desired mode 配置,`go-plugin-process`/`sandbox-process` 未来生效且切换需要重启 |
### 准入和权限

View File

@@ -8,3 +8,5 @@ This example demonstrates phase 7 extension points:
- `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.
The source manifest is maintained as `manifest.yaml`; packaged `.mcgp` artifacts
still contain canonical `manifest.json`.

View File

@@ -1,34 +0,0 @@
{
"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"
},
"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

@@ -0,0 +1,51 @@
# examples/plugins/extension-ecosystem/manifest.yaml 是示例插件代码,用于演示托管插件接入方式。
# 人工维护的插件清单;构建插件包时会规范化为 manifest.json。
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
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

@@ -12,16 +12,19 @@ belong inside a protocol-proxy plugin.
Build and package:
```sh
./build.sh
(cd ../../.. && go run ./cmd/gateway plugin test examples/plugins/mc-auth-proxy --profile manifest)
(cd ../../.. && go run ./cmd/gateway plugin build examples/plugins/mc-auth-proxy --type both)
```
The binary package is written to `dist/mc-auth-proxy.mcgp`; the source package
is written to `dist/mc-auth-proxy-source.mcgp`.
The source manifest is maintained as `manifest.yaml`; packaged `.mcgp` artifacts
still contain canonical `manifest.json`.
Build the source package through the gateway builder:
```sh
go run ../../../cmd/gateway plugin source-build dist/mc-auth-proxy-source.mcgp dist/mc-auth-proxy-built.mcgp
(cd ../../.. && go run ./cmd/gateway plugin build --from-source examples/plugins/mc-auth-proxy/dist/mc-auth-proxy-source.mcgp --out examples/plugins/mc-auth-proxy/dist/mc-auth-proxy-built.mcgp)
```
Example config JSON:

View File

@@ -1,23 +1,8 @@
#!/usr/bin/env sh
# examples/plugins/mc-auth-proxy/build.sh 是示例插件代码,用于演示托管插件接入方式。
set -eu
mkdir -p dist
go build -buildmode=plugin -o dist/plugin.so .
go run ./cmd/render-manifest > dist/manifest.json
cp README.md dist/README.md
(
cd dist
rm -f mc-auth-proxy.mcgp
zip -q mc-auth-proxy.mcgp manifest.json plugin.so README.md
)
rm -rf dist/source-package
mkdir -p dist/source-package/cmd/render-manifest
ARTIFACT_TYPE=source go run ./cmd/render-manifest > dist/source-package/manifest.json
cp main.go main_test.go go.mod README.md dist/source-package/
cp cmd/render-manifest/main.go dist/source-package/cmd/render-manifest/main.go
go mod vendor -o dist/source-package/vendor
(
cd dist/source-package
rm -f ../mc-auth-proxy-source.mcgp
zip -qr ../mc-auth-proxy-source.mcgp manifest.json main.go main_test.go go.mod README.md cmd/render-manifest/main.go vendor
)
repo_root=$(cd ../../.. && pwd)
cd "$repo_root"
go run ./cmd/gateway plugin build examples/plugins/mc-auth-proxy --type both --skip-tests

View File

@@ -1,44 +0,0 @@
package main
import (
"encoding/json"
"os"
"runtime"
)
func main() {
if err := renderManifest(os.Stdout, os.Getenv("ARTIFACT_TYPE")); err != nil {
panic(err)
}
}
func renderManifest(out *os.File, artifactType string) error {
data, err := os.ReadFile("manifest.json")
if err != nil {
return err
}
var manifest map[string]any
if err := json.Unmarshal(data, &manifest); err != nil {
return err
}
if artifactType != "" {
manifest["artifact_type"] = artifactType
}
manifest["go_version"] = runtime.Version()
manifest["go_os"] = runtime.GOOS
manifest["go_arch"] = runtime.GOARCH
if manifest["artifact_type"] == "source" {
manifest["build"] = map[string]any{
"type": "go",
"entry": ".",
"go_version": runtime.Version(),
"cgo_enabled": true,
"tags": []string{},
"vendor_required": false,
"output": "plugin.so",
}
}
encoder := json.NewEncoder(out)
encoder.SetIndent("", " ")
return encoder.Encode(manifest)
}

View File

@@ -1,3 +1,5 @@
// examples/plugins/mc-auth-proxy/main.go 演示托管插件如何拦截登录流量、发出认证事件并按条件拒绝客户端。
package main
import (

View File

@@ -1,3 +1,5 @@
// examples/plugins/mc-auth-proxy/main_test.go 包含用于约束 mc auth proxy 行为的测试。
package main
import (

View File

@@ -1,94 +0,0 @@
{
"schema_version": "mc-gateway.plugin/v1",
"id": "mc-auth-proxy",
"name": "Minecraft Auth Proxy",
"version": "0.1.0",
"description": "Protocol-proxy example that owns Minecraft login handling and returns a stable fixture disconnect.",
"artifact_type": "binary",
"runtime": {
"type": "go-plugin",
"entry": "plugin.so",
"entry_symbol": "Plugin"
},
"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": "hook", "key": "upstream.connect/v1" }
],
"capabilities": {
"upstream_connect": { "mode": "protocol-proxy" },
"minecraft": {
"protocol_versions": {
"min": 47,
"max": 767,
"tested": [47, 760, 763, 767],
"unsupported_policy": "kick"
},
"states": {
"status": "transparent",
"login": "handled",
"configuration": "transparent",
"play": "transparent"
},
"auth_modes": ["fixture"],
"forwarding": {
"supported": ["none", "velocity-modern"],
"default": "none",
"requires_secret": false
},
"unsupported_policy": "kick",
"modded": {
"forge": "transparent",
"fabric": "transparent",
"fml": "unsupported",
"unknown": "pass"
}
}
},
"runtime_limits": {
"handler_timeout_ms": 3000,
"initial_write_timeout_ms": 1000
},
"events": [
{ "name": "auth.success", "fields": ["result", "mode"] },
{ "name": "auth.failure", "fields": ["result", "mode"] }
],
"custom_metrics": [
{ "name": "auth.attempts", "type": "counter", "labels": ["result", "mode"] }
],
"external_dependencies": [
{
"name": "backend",
"endpoint": "tcp://",
"purpose": "auth",
"required": true,
"timeout": "3s",
"retry": 0,
"fail_policy": "fail_closed",
"data_classes": ["operational"]
}
],
"background_tasks": [
{ "id": "profile-cache-gc", "name": "Profile cache GC", "mode": "manual", "manual": true, "timeout": "1s" }
],
"data_stores": [
{ "name": "profile-cache", "schema_version": 1, "data_class": "profile_cache", "quota_bytes": 1048576, "retention": "24h", "exportable": false }
],
"file_stores": [
{ "namespace": "cache", "data_class": "profile_cache", "quota_bytes": 1048576, "retention": "24h" },
{ "namespace": "diagnostic", "data_class": "diagnostic", "quota_bytes": 1048576, "retention": "24h" }
],
"config_schema": {
"type": "object",
"properties": {
"match_host": { "type": "string" },
"fixture_accept": { "type": "boolean" },
"disconnect_message": { "type": "string" },
"backend": { "type": "string" }
}
}
}

View File

@@ -0,0 +1,115 @@
# examples/plugins/mc-auth-proxy/manifest.yaml 是示例插件代码,用于演示托管插件接入方式。
# 人工维护的插件清单;构建插件包时会规范化为 manifest.json。
schema_version: mc-gateway.plugin/v1
id: mc-auth-proxy
name: Minecraft Auth Proxy
version: 0.1.0
description: Protocol-proxy example that owns Minecraft login handling and returns a stable fixture disconnect.
artifact_type: binary
runtime:
type: go-plugin
entry: plugin.so
entry_symbol: Plugin
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: hook
key: upstream.connect/v1
capabilities:
upstream_connect:
mode: protocol-proxy
minecraft:
protocol_versions:
min: 47
max: 767
tested:
- 47
- 760
- 763
- 767
unsupported_policy: kick
states:
status: transparent
login: handled
configuration: transparent
play: transparent
auth_modes:
- fixture
forwarding:
supported:
- none
- velocity-modern
default: none
requires_secret: false
unsupported_policy: kick
modded:
forge: transparent
fabric: transparent
fml: unsupported
unknown: pass
runtime_limits:
handler_timeout_ms: 3000
initial_write_timeout_ms: 1000
events:
- name: auth.success
fields:
- result
- mode
- name: auth.failure
fields:
- result
- mode
custom_metrics:
- name: auth.attempts
type: counter
labels:
- result
- mode
external_dependencies:
- name: backend
endpoint: tcp://
purpose: auth
required: true
timeout: 3s
retry: 0
fail_policy: fail_closed
data_classes:
- operational
background_tasks:
- id: profile-cache-gc
name: Profile cache GC
mode: manual
manual: true
timeout: 1s
data_stores:
- name: profile-cache
schema_version: 1
data_class: profile_cache
quota_bytes: 1048576
retention: 24h
exportable: false
file_stores:
- namespace: cache
data_class: profile_cache
quota_bytes: 1048576
retention: 24h
- namespace: diagnostic
data_class: diagnostic
quota_bytes: 1048576
retention: 24h
config_schema:
type: object
properties:
match_host:
type: string
fixture_accept:
type: boolean
disconnect_message:
type: string
backend:
type: string

View File

@@ -0,0 +1,5 @@
{
"match_host": "play.example",
"fixture_accept": false,
"disconnect_message": "Authentication fixture rejected the login"
}

View File

@@ -8,16 +8,19 @@ return `api.ErrPass`.
Build and package:
```sh
./build.sh
(cd ../../.. && go run ./cmd/gateway plugin test examples/plugins/upstream-rewrite --profile manifest)
(cd ../../.. && go run ./cmd/gateway plugin build examples/plugins/upstream-rewrite --type both)
```
The binary package is written to `dist/upstream-rewrite.mcgp`; the source
package is written to `dist/upstream-rewrite-source.mcgp`.
The source manifest is maintained as `manifest.yaml`; packaged `.mcgp` artifacts
still contain canonical `manifest.json`.
Build the source package through the gateway builder:
```sh
go run ../../../cmd/gateway plugin source-build dist/upstream-rewrite-source.mcgp dist/upstream-rewrite-built.mcgp
(cd ../../.. && go run ./cmd/gateway plugin build --from-source examples/plugins/upstream-rewrite/dist/upstream-rewrite-source.mcgp --out examples/plugins/upstream-rewrite/dist/upstream-rewrite-built.mcgp)
```
Example config JSON:

View File

@@ -1,23 +1,8 @@
#!/usr/bin/env sh
# examples/plugins/upstream-rewrite/build.sh 是示例插件代码,用于演示托管插件接入方式。
set -eu
mkdir -p dist
go build -buildmode=plugin -o dist/plugin.so .
go run ./cmd/render-manifest > dist/manifest.json
cp README.md dist/README.md
(
cd dist
rm -f upstream-rewrite.mcgp
zip -q upstream-rewrite.mcgp manifest.json plugin.so README.md
)
rm -rf dist/source-package
mkdir -p dist/source-package/cmd/render-manifest
ARTIFACT_TYPE=source go run ./cmd/render-manifest > dist/source-package/manifest.json
cp main.go go.mod README.md dist/source-package/
cp cmd/render-manifest/main.go dist/source-package/cmd/render-manifest/main.go
go mod vendor -o dist/source-package/vendor
(
cd dist/source-package
rm -f ../upstream-rewrite-source.mcgp
zip -qr ../upstream-rewrite-source.mcgp manifest.json main.go go.mod README.md cmd/render-manifest/main.go vendor
)
repo_root=$(cd ../../.. && pwd)
cd "$repo_root"
go run ./cmd/gateway plugin build examples/plugins/upstream-rewrite --type both --skip-tests

View File

@@ -1,44 +0,0 @@
package main
import (
"encoding/json"
"os"
"runtime"
)
func main() {
if err := renderManifest(os.Stdout, os.Getenv("ARTIFACT_TYPE")); err != nil {
panic(err)
}
}
func renderManifest(out *os.File, artifactType string) error {
data, err := os.ReadFile("manifest.json")
if err != nil {
return err
}
var manifest map[string]any
if err := json.Unmarshal(data, &manifest); err != nil {
return err
}
if artifactType != "" {
manifest["artifact_type"] = artifactType
}
manifest["go_version"] = runtime.Version()
manifest["go_os"] = runtime.GOOS
manifest["go_arch"] = runtime.GOARCH
if manifest["artifact_type"] == "source" {
manifest["build"] = map[string]any{
"type": "go",
"entry": ".",
"go_version": runtime.Version(),
"cgo_enabled": true,
"tags": []string{},
"vendor_required": false,
"output": "plugin.so",
}
}
encoder := json.NewEncoder(out)
encoder.SetIndent("", " ")
return encoder.Encode(manifest)
}

View File

@@ -1,3 +1,5 @@
// examples/plugins/upstream-rewrite/main.go 演示托管插件如何在网关拨号上游前改写路由决策。
package main
import (

View File

@@ -1,39 +0,0 @@
{
"schema_version": "mc-gateway.plugin/v1",
"id": "upstream-rewrite",
"name": "Upstream Rewrite",
"version": "0.1.0",
"description": "Rewrite selected upstream targets before dialing.",
"artifact_type": "binary",
"runtime": {
"type": "go-plugin",
"entry": "plugin.so",
"entry_symbol": "Plugin"
},
"api_version": "plugin-api/v1",
"sdk_module": "github.com/tursom/mc-gateway/plugin/api",
"sdk_module_version": "v0.1.0",
"go_version": "go1.24.4",
"go_os": "linux",
"go_arch": "amd64",
"extension_points": [
{ "type": "hook", "key": "upstream.connect/v1" }
],
"capabilities": {
"extension_points": ["upstream.connect/v1"],
"network": { "outbound": ["tcp:*:*"] },
"filesystem": { "read": [], "write": [] },
"env": []
},
"runtime_limits": {
"handler_timeout_ms": 3000
},
"config_schema": {
"type": "object",
"properties": {
"match_host": { "type": "string" },
"upstream": { "type": "string" }
},
"required": ["upstream"]
}
}

View File

@@ -0,0 +1,43 @@
# examples/plugins/upstream-rewrite/manifest.yaml 是示例插件代码,用于演示托管插件接入方式。
# 人工维护的插件清单;构建插件包时会规范化为 manifest.json。
schema_version: mc-gateway.plugin/v1
id: upstream-rewrite
name: Upstream Rewrite
version: 0.1.0
description: Rewrite selected upstream targets before dialing.
artifact_type: binary
runtime:
type: go-plugin
entry: plugin.so
entry_symbol: Plugin
api_version: plugin-api/v1
sdk_module: github.com/tursom/mc-gateway/plugin/api
sdk_module_version: v0.1.0
go_version: go1.24.4
go_os: linux
go_arch: amd64
extension_points:
- type: hook
key: upstream.connect/v1
capabilities:
extension_points:
- upstream.connect/v1
network:
outbound:
- tcp:*:*
filesystem:
read: []
write: []
env: []
runtime_limits:
handler_timeout_ms: 3000
config_schema:
type: object
properties:
match_host:
type: string
upstream:
type: string
required:
- upstream

View File

@@ -0,0 +1,4 @@
{
"match_host": "play.example",
"upstream": "127.0.0.1:25566"
}

2
go.mod
View File

@@ -7,11 +7,13 @@ toolchain go1.24.4
require (
github.com/gorilla/websocket v1.5.3
github.com/mitchellh/mapstructure v1.5.0
github.com/pelletier/go-toml/v2 v2.4.2
github.com/pires/go-proxyproto v0.8.1
github.com/quic-go/quic-go v0.52.0
github.com/rs/zerolog v1.33.0
github.com/xtaci/kcp-go v5.4.20+incompatible
golang.org/x/crypto v0.43.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.45.0
)

3
go.sum
View File

@@ -62,6 +62,8 @@ github.com/onsi/ginkgo/v2 v2.9.5 h1:+6Hr4uxzP4XIUyAkg61dWBw8lb/gc4/X5luuxN/EC+Q=
github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k=
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
github.com/pelletier/go-toml/v2 v2.4.2 h1:M2fKKbmyvI+hGId/D0W64qDBMVhJnNR10O5gIbMc//Q=
github.com/pelletier/go-toml/v2 v2.4.2/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0=
github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -157,6 +159,7 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

Some files were not shown because too many files have changed in this diff Show More