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

This commit is contained in:
2026-06-27 20:15:29 +08:00
parent 7a5664ab89
commit f508ecc1b9
145 changed files with 774 additions and 41 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 是连接热路径读取的不可变快照;写路径通过 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 (

View File

@@ -1,3 +1,5 @@
// cmd/gateway/plugin_cli_manifest.go 实现插件包清单的查看、校验、特性列表和格式化命令。
package main
import (

View File

@@ -1,3 +1,5 @@
// cmd/gateway/plugin_cli_remote.go 实现通过 Admin API 驱动插件管理操作的命令行客户端。
package main
import (

View File

@@ -1,3 +1,5 @@
// cmd/gateway/plugin_cli_toolchain.go 包含插件开发者使用的本地源码模板、构建打包流程和测试辅助逻辑。
package main
import (
@@ -1669,7 +1671,7 @@ func manifestYAMLTemplate(opts pluginInitCLIOptions) string {
if opts.Template == "protocol-proxy" {
mode = pluginmanager.UpstreamModeProtocolProxy
}
return fmt.Sprintf(`# Human-maintained plugin manifest. Build packages normalize this into manifest.json.
return fmt.Sprintf(`# 人工维护的插件清单;构建插件包时会规范化为 manifest.json
schema_version: mc-gateway.plugin/v1
id: %q
name: %q
@@ -1713,7 +1715,7 @@ func manifestTOMLTemplate(opts pluginInitCLIOptions) string {
if opts.Template == "protocol-proxy" {
mode = pluginmanager.UpstreamModeProtocolProxy
}
return fmt.Sprintf(`# Human-maintained plugin manifest. Build packages normalize this into manifest.json.
return fmt.Sprintf(`# 人工维护的插件清单;构建插件包时会规范化为 manifest.json
schema_version = "mc-gateway.plugin/v1"
id = %q
name = %q

View File

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

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 (
@@ -13,10 +15,12 @@ import (
const (
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

@@ -1,4 +1,6 @@
# Human-maintained plugin manifest. Build packages normalize this into manifest.json.
# examples/plugins/extension-ecosystem/manifest.yaml 是示例插件代码,用于演示托管插件接入方式。
# 人工维护的插件清单;构建插件包时会规范化为 manifest.json。
schema_version: mc-gateway.plugin/v1
id: extension-ecosystem-example
name: Extension Ecosystem Example

View File

@@ -1,4 +1,6 @@
#!/usr/bin/env sh
# examples/plugins/mc-auth-proxy/build.sh 是示例插件代码,用于演示托管插件接入方式。
set -eu
repo_root=$(cd ../../.. && pwd)

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,4 +1,6 @@
# Human-maintained plugin manifest. Build packages normalize this into manifest.json.
# examples/plugins/mc-auth-proxy/manifest.yaml 是示例插件代码,用于演示托管插件接入方式。
# 人工维护的插件清单;构建插件包时会规范化为 manifest.json。
schema_version: mc-gateway.plugin/v1
id: mc-auth-proxy
name: Minecraft Auth Proxy

View File

@@ -1,4 +1,6 @@
#!/usr/bin/env sh
# examples/plugins/upstream-rewrite/build.sh 是示例插件代码,用于演示托管插件接入方式。
set -eu
repo_root=$(cd ../../.. && pwd)

View File

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

View File

@@ -1,4 +1,6 @@
# Human-maintained plugin manifest. Build packages normalize this into manifest.json.
# examples/plugins/upstream-rewrite/manifest.yaml 是示例插件代码,用于演示托管插件接入方式。
# 人工维护的插件清单;构建插件包时会规范化为 manifest.json。
schema_version: mc-gateway.plugin/v1
id: upstream-rewrite
name: Upstream Rewrite

View File

@@ -1,3 +1,5 @@
// internal/adminaudit/audit.go 持久化 Admin API 变更产生的审计事件,并向管理界面提供查询。
package adminaudit
import (

View File

@@ -1,3 +1,5 @@
// internal/adminaudit/audit_test.go 包含用于约束 audit 行为的测试。
package adminaudit
import (

View File

@@ -1,3 +1,5 @@
// internal/adminconfig/config.go 在 SQLite 中保存服务级运行态配置,并用类型化方法包装 JSON 选项。
package adminconfig
import (

View File

@@ -1,3 +1,5 @@
// internal/adminconfig/config_test.go 包含用于约束 config 行为的测试。
package adminconfig
import "testing"

View File

@@ -1,5 +1,7 @@
//go:build (darwin && (amd64 || arm64)) || (freebsd && (amd64 || arm64)) || (linux && (386 || amd64 || arm || arm64 || loong64 || ppc64le || riscv64 || s390x)) || (openbsd && (amd64 || arm64)) || (windows && (386 || amd64 || arm64))
// internal/admindb/db.go 打开跨平台 SQLite 数据库,并应用管理、路由和插件运行态共用的表结构。
package admindb
import (
@@ -7,9 +9,12 @@ import (
"os"
"path/filepath"
// modernc.org/sqlite 是纯 Go SQLite 驱动,便于容器和跨平台构建时避免 CGO 依赖。
_ "modernc.org/sqlite"
)
// Open 创建或打开管理运行态数据库。调用方传入的路径可以包含尚不存在的目录,
// 这里会先创建目录,再打开 SQLite 连接。
func Open(dbPath string) (*sql.DB, error) {
if dir := filepath.Dir(dbPath); dir != "." && dir != "" {
if err := os.MkdirAll(dir, 0755); err != nil {
@@ -21,12 +26,16 @@ func Open(dbPath string) (*sql.DB, error) {
if err != nil {
return nil, err
}
// SQLite 对单写者最友好;限制连接数可以避免 database/sql 在高并发下
// 打开多条连接后互相争用写锁。
db.SetMaxOpenConns(1)
// WAL 让读请求不会被普通写事务完全阻塞,适合管理端读多写少的状态库。
if _, err := db.Exec(`PRAGMA journal_mode=WAL`); err != nil {
db.Close()
return nil, err
}
// 写锁短暂冲突时等待一小段时间,减少管理端并发操作产生的偶发 busy 错误。
if _, err := db.Exec(`PRAGMA busy_timeout=5000`); err != nil {
db.Close()
return nil, err
@@ -35,6 +44,8 @@ func Open(dbPath string) (*sql.DB, error) {
return db, nil
}
// Migrate 以幂等方式应用当前 schema。所有 CREATE TABLE 都使用
// IF NOT EXISTS后续字段演进通过 ensureColumn 补齐,便于老数据库平滑升级。
func Migrate(db *sql.DB) error {
const schema = `
CREATE TABLE IF NOT EXISTS schema_migrations (
@@ -447,6 +458,8 @@ INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (1, strftime
if _, err := db.Exec(schema); err != nil {
return err
}
// CREATE TABLE 不会修改已存在的表,因此历史版本新增字段需要显式补齐。
// 每个 ensureColumn 都是幂等的,可以安全地在每次启动迁移时执行。
if err := ensureColumn(db, "audit_logs", "metadata_json", "TEXT NOT NULL DEFAULT '{}'"); err != nil {
return err
}
@@ -465,6 +478,8 @@ INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (1, strftime
return nil
}
// ensureColumn 在表缺字段时执行 ALTER TABLE。table/column/definition 只由
// 受控迁移代码传入,不接收外部输入,避免把 PRAGMA 语句做成动态用户入口。
func ensureColumn(db *sql.DB, table, column, definition string) error {
rows, err := db.Query(`PRAGMA table_info(` + table + `)`)
if err != nil {

View File

@@ -1,3 +1,5 @@
// internal/admindb/db_test.go 包含用于约束 db 行为的测试。
package admindb
import (

View File

@@ -1,5 +1,7 @@
//go:build !((darwin && (amd64 || arm64)) || (freebsd && (amd64 || arm64)) || (linux && (386 || amd64 || arm || arm64 || loong64 || ppc64le || riscv64 || s390x)) || (openbsd && (amd64 || arm64)) || (windows && (386 || amd64 || arm64)))
// internal/admindb/db_unsupported.go 在纯 Go SQLite 驱动未被构建标签启用的平台上返回明确错误。
package admindb
import (

View File

@@ -1,3 +1,5 @@
// internal/adminhttp/api.go 构建隔离的进程内 Admin API 服务,供测试和包级消费者使用。
package adminhttp
import (

View File

@@ -1,3 +1,5 @@
// internal/adminhttp/api_test.go 包含用于约束 api 行为的测试。
package adminhttp
import (

View File

@@ -1,3 +1,5 @@
// internal/adminhttp/gateway.go 把仓库驱动的网关依赖适配为 Admin API 处理器集合。
package adminhttp
import (

View File

@@ -1,3 +1,5 @@
// internal/adminhttp/http.go 提供 Admin API 处理器共用的 JSON、路径片段和来源 IP 辅助方法。
package adminhttp
import (

View File

@@ -1,3 +1,5 @@
// internal/adminhttp/http_test.go 包含用于约束 http 行为的测试。
package adminhttp
import (

View File

@@ -1,3 +1,5 @@
// internal/adminhttp/requests.go 定义 Admin API 测试网关和命令处理器接受的请求载荷。
package adminhttp
type LoginRequest struct {

View File

@@ -1,3 +1,5 @@
// internal/adminhttp/requests_test.go 包含用于约束 requests 行为的测试。
package adminhttp
import (

View File

@@ -1,3 +1,5 @@
// internal/adminroute/repository.go 持久化 Minecraft 主机路由记录,并为在线网关返回有序快照。
package adminroute
import (
@@ -19,6 +21,7 @@ type Record struct {
type Repository struct {
db *sql.DB
// now 可在测试中注入固定时间,避免断言依赖真实时钟。
now func() time.Time
}
@@ -38,6 +41,7 @@ func NewRepositoryWithClock(db *sql.DB, now func() time.Time) Repository {
}
func (r Repository) EnabledMap(ctx context.Context) (map[string]string, error) {
// 只读取启用路由,结果直接用于连接热路径的内存快照。
rows, err := r.db.QueryContext(ctx, `SELECT host, upstream FROM routes WHERE enabled = 1`)
if err != nil {
return nil, err
@@ -61,6 +65,7 @@ SELECT host, upstream, enabled, note, created_at, updated_at, updated_by
FROM routes`
var args []any
if query = strings.TrimSpace(query); query != "" {
// 管理端搜索同时覆盖 host、upstream 和 note便于按服务名或备注定位路由。
sqlQuery += ` WHERE host LIKE ? OR upstream LIKE ? OR note LIKE ?`
like := "%" + query + "%"
args = append(args, like, like, like)
@@ -87,6 +92,7 @@ FROM routes`
}
func (r Repository) Upsert(ctx context.Context, actor, host, upstream string, enabled bool, note string) error {
// 写入前统一校验,避免无效 host/upstream 进入 SQLite 后再被热路径读取。
if err := ValidateHost(host); err != nil {
return err
}
@@ -101,6 +107,7 @@ func (r Repository) Upsert(ctx context.Context, actor, host, upstream string, en
}
defer tx.Rollback()
// host 是主键;重复保存时只更新可变字段并保留 created_at。
if _, err := tx.ExecContext(ctx, `
INSERT INTO routes(host, upstream, enabled, note, created_at, updated_at, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?)
@@ -117,6 +124,7 @@ ON CONFLICT(host) DO UPDATE SET
}
func (r Repository) Delete(ctx context.Context, host string) error {
// 删除同样校验 host防止管理端路径参数中的非法值直接进入 SQL。
if err := ValidateHost(host); err != nil {
return err
}

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