Compare commits
7 Commits
13ab1f6964
...
f4a11fb770
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4a11fb770 | ||
|
|
c21edfdf1a | ||
|
|
ecb682bb41 | ||
|
|
bf6db8c455 | ||
|
|
4fbba07b27 | ||
|
|
49381901df | ||
|
|
04ec53bc51 |
22
.dockerignore
Normal file
22
.dockerignore
Normal file
@@ -0,0 +1,22 @@
|
||||
.git
|
||||
.agents
|
||||
.codex
|
||||
.omc
|
||||
.vscode
|
||||
node_modules
|
||||
cmd/gateway/admin_static/js
|
||||
|
||||
logs
|
||||
data
|
||||
gateway
|
||||
gateway-*
|
||||
kcp
|
||||
kcp-*
|
||||
quic
|
||||
quic-*
|
||||
|
||||
*.so
|
||||
*.sqlite3
|
||||
*.sqlite3-*
|
||||
|
||||
config.toml
|
||||
60
.github/workflows/docker-image.yml
vendored
Normal file
60
.github/workflows/docker-image.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
name: Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["master"]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches: ["master"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha,prefix=sha-
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,9 +1,12 @@
|
||||
.vscode
|
||||
node_modules/
|
||||
cmd/gateway/admin_static/js/
|
||||
|
||||
config.toml
|
||||
/gateway*
|
||||
/kcp*
|
||||
/quic*
|
||||
/logs/
|
||||
/data/
|
||||
|
||||
.omc
|
||||
|
||||
45
Dockerfile
Normal file
45
Dockerfile
Normal file
@@ -0,0 +1,45 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM --platform=$BUILDPLATFORM node:24.11.1-alpine AS admin-frontend
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY package.json package-lock.json tsconfig.admin.json ./
|
||||
RUN --mount=type=cache,id=mc-gateway-npm,target=/root/.npm,sharing=locked \
|
||||
npm ci
|
||||
|
||||
COPY cmd/gateway/admin_frontend ./cmd/gateway/admin_frontend
|
||||
COPY cmd/gateway/admin_static/index.html cmd/gateway/admin_static/app.css ./cmd/gateway/admin_static/
|
||||
RUN npm run build:admin
|
||||
|
||||
FROM --platform=$BUILDPLATFORM golang:1.24.4-alpine AS build
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=cache,id=mc-gateway-go-mod,target=/go/pkg/mod,sharing=locked \
|
||||
go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN --mount=type=cache,id=mc-gateway-go-mod,target=/go/pkg/mod,sharing=locked \
|
||||
--mount=type=cache,id=mc-gateway-go-build-${TARGETOS}-${TARGETARCH},target=/root/.cache/go-build,sharing=locked \
|
||||
mkdir -p /out \
|
||||
&& CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags="-s -w" -o /out/mc-gateway ./cmd/gateway
|
||||
|
||||
FROM alpine:3.22
|
||||
|
||||
WORKDIR /data
|
||||
|
||||
COPY --from=build /out/mc-gateway /usr/local/bin/mc-gateway
|
||||
COPY --from=admin-frontend /src/cmd/gateway/admin_static /usr/share/mc-gateway/admin_static
|
||||
|
||||
ENV MC_GATEWAY_DB=/data/mc-gateway.sqlite3
|
||||
ENV MC_GATEWAY_ADMIN_STATIC_DIR=/usr/share/mc-gateway/admin_static
|
||||
|
||||
EXPOSE 25565/tcp
|
||||
|
||||
CMD ["/usr/local/bin/mc-gateway"]
|
||||
54
README.md
54
README.md
@@ -19,11 +19,65 @@ mc-gateway 默认不依赖配置文件。直接启动后会在 `25565` 端口同
|
||||
| `MC_GATEWAY_TCP_ADMIN_PORT` | `25565` | TCP/Admin 共享监听端口 |
|
||||
| `MC_GATEWAY_ADMIN_PATH` | `/admin/` | Admin 页面路径 |
|
||||
| `MC_GATEWAY_ADMIN_API_PREFIX` | `/admin/api` | Admin API 前缀 |
|
||||
| `MC_GATEWAY_ADMIN_STATIC_DIR` | `cmd/gateway/admin_static` | Admin 前端静态文件目录;Docker 镜像中为 `/usr/share/mc-gateway/admin_static` |
|
||||
| `MC_GATEWAY_DB` | `mc-gateway.sqlite3` | SQLite 数据库路径 |
|
||||
| `MC_GATEWAY_ADMIN_PASSWORD` | 空 | 首次启动时创建默认管理员密码 |
|
||||
|
||||
服务启停、KCP/QUIC/WebSocket 参数、用户、权限和路由都通过后台管理写入 SQLite,不再使用 `config.toml` 作为启动配置或路由来源。
|
||||
|
||||
### Docker Compose
|
||||
|
||||
生产部署只需要 `compose.yaml`,可以直接用 Docker Compose 拉取已发布镜像并启动:
|
||||
|
||||
```sh
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
源码目录中包含 `compose.override.yaml`,Docker Compose 会自动加载它,因此本地构建测试仍然可以直接运行:
|
||||
|
||||
```sh
|
||||
docker compose build
|
||||
```
|
||||
|
||||
默认使用 host network,在宿主机 `25565/tcp` 提供 Minecraft TCP 转发入口和后台管理入口,后台地址为:
|
||||
|
||||
```text
|
||||
http://<host>:25565/admin/
|
||||
```
|
||||
|
||||
Compose 使用本地 `./data` 目录持久化 SQLite 数据库和 WAL 文件,不挂载旧 `config.toml`。首次启动可以在后台页面初始化管理员,也可以通过 `.env` 预置默认管理员 `admin` 的密码:
|
||||
|
||||
```env
|
||||
MC_GATEWAY_ADMIN_PASSWORD=change-me
|
||||
```
|
||||
|
||||
常用可选项:
|
||||
|
||||
```env
|
||||
MC_GATEWAY_TCP_ADMIN_PORT=25565
|
||||
MC_GATEWAY_ADMIN_PATH=/admin/
|
||||
MC_GATEWAY_ADMIN_API_PREFIX=/admin/api
|
||||
MC_GATEWAY_ADMIN_STATIC_DIR=/usr/share/mc-gateway/admin_static
|
||||
MC_GATEWAY_DB=/data/mc-gateway.sqlite3
|
||||
```
|
||||
|
||||
### Admin 前端开发
|
||||
|
||||
Admin 前端源码位于 `cmd/gateway/admin_frontend/src`,使用 TypeScript 拆分为原生 ES modules。构建产物输出到 `cmd/gateway/admin_static/js`,Go 服务不会 embed 前端文件,而是从 `MC_GATEWAY_ADMIN_STATIC_DIR` 指向的目录透传静态响应。
|
||||
|
||||
修改前端后运行:
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run build:admin
|
||||
```
|
||||
|
||||
`master` 分支和 `v*` tag 会通过 GitHub Actions 构建并推送 Docker 镜像到 GitHub Container Registry:
|
||||
|
||||
```text
|
||||
ghcr.io/tursom/mc-gateway:latest
|
||||
```
|
||||
|
||||
## 权限
|
||||
|
||||
后台管理内置三类角色:
|
||||
|
||||
@@ -28,5 +28,12 @@ func newAdminAPIHandler() http.HandlerFunc {
|
||||
UserItem: handleAdminUserItem,
|
||||
|
||||
AuditLogs: handleAdminAuditLogs,
|
||||
|
||||
PluginArtifacts: handleAdminPluginArtifacts,
|
||||
PluginArtifact: handleAdminPluginArtifact,
|
||||
PluginsList: handleAdminPluginsList,
|
||||
PluginItem: handleAdminPluginItem,
|
||||
PluginAction: handleAdminPluginAction,
|
||||
PluginDispatch: handleAdminPluginDispatchPlan,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -122,8 +122,16 @@ func TestAdminCustomPathAndAPIPrefixFromEnv(t *testing.T) {
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("custom admin page status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), `data-api-prefix="/ops/api"`) {
|
||||
t.Fatalf("custom admin page does not contain API prefix: %s", resp.Body.String())
|
||||
if !strings.Contains(resp.Body.String(), `script src="config.js"`) {
|
||||
t.Fatalf("custom admin page does not reference runtime config: %s", resp.Body.String())
|
||||
}
|
||||
|
||||
resp = adminTestRequest(t, handler, http.MethodGet, "/ops/config.js", "", nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("custom admin config status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if strings.TrimSpace(resp.Body.String()) != `window.MCGatewayAdmin={"apiPrefix":"/ops/api"};` {
|
||||
t.Fatalf("custom admin config body = %q", resp.Body.String())
|
||||
}
|
||||
|
||||
resp = adminTestRequest(t, handler, http.MethodGet, "/ops/api/setup", "", nil)
|
||||
|
||||
@@ -10,6 +10,10 @@ func recordAudit(ctx context.Context, actor, sourceIP, action, targetType, targe
|
||||
_ = adminaudit.NewRepository(adminDB).Record(ctx, actor, sourceIP, action, targetType, targetID, success, message)
|
||||
}
|
||||
|
||||
func recordAuditMetadata(ctx context.Context, actor, sourceIP, action, targetType, targetID string, success bool, message string, metadata any) {
|
||||
_ = adminaudit.NewRepository(adminDB).RecordWithMetadata(ctx, actor, sourceIP, action, targetType, targetID, success, message, metadata)
|
||||
}
|
||||
|
||||
func listAuditLogs(ctx context.Context) ([]adminaudit.Record, error) {
|
||||
return adminaudit.NewRepository(adminDB).List(ctx, adminaudit.DefaultListLimit)
|
||||
}
|
||||
|
||||
8
cmd/gateway/admin_frontend/src/alerts.ts
Normal file
8
cmd/gateway/admin_frontend/src/alerts.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { el } from "./dom.js";
|
||||
import { localizeMessage } from "./i18n.js";
|
||||
|
||||
export function showAlert(message: unknown): void {
|
||||
const box = el("alert");
|
||||
box.textContent = localizeMessage(message);
|
||||
box.classList.toggle("hidden", !message);
|
||||
}
|
||||
46
cmd/gateway/admin_frontend/src/api.ts
Normal file
46
cmd/gateway/admin_frontend/src/api.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { state } from "./state.js";
|
||||
|
||||
interface APIOptions {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
export async function api<T>(path: string, options: APIOptions = {}): Promise<T> {
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
if (options.body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
if (state.token) {
|
||||
headers.Authorization = `Bearer ${state.token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(state.apiBase + path, {
|
||||
method: options.method || "GET",
|
||||
headers,
|
||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
});
|
||||
|
||||
let data: unknown = {};
|
||||
const text = await res.text();
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
data = { error: text };
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(apiErrorMessage(data, res.statusText));
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
function apiErrorMessage(data: unknown, fallback: string): string {
|
||||
if (data && typeof data === "object" && "error" in data) {
|
||||
const error = (data as { error?: unknown }).error;
|
||||
if (typeof error === "string" && error) {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
13
cmd/gateway/admin_frontend/src/config.ts
Normal file
13
cmd/gateway/admin_frontend/src/config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { RuntimeConfig } from "./types.js";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
MCGatewayAdmin?: Partial<RuntimeConfig>;
|
||||
}
|
||||
}
|
||||
|
||||
export function runtimeConfig(): RuntimeConfig {
|
||||
return {
|
||||
apiPrefix: window.MCGatewayAdmin?.apiPrefix || "/admin/api",
|
||||
};
|
||||
}
|
||||
53
cmd/gateway/admin_frontend/src/dom.ts
Normal file
53
cmd/gateway/admin_frontend/src/dom.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export function el<T extends HTMLElement = HTMLElement>(id: string): T {
|
||||
const node = document.getElementById(id);
|
||||
if (!node) {
|
||||
throw new Error(`missing element #${id}`);
|
||||
}
|
||||
return node as T;
|
||||
}
|
||||
|
||||
export function escapeHTML(value: unknown): string {
|
||||
return String(value).replace(/[&<>"']/g, (ch) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
"\"": """,
|
||||
"'": "'",
|
||||
}[ch] || ch));
|
||||
}
|
||||
|
||||
export function escapeAttr(value: unknown): string {
|
||||
return escapeHTML(value).replace(/`/g, "`");
|
||||
}
|
||||
|
||||
export function stat(label: string, value: unknown): string {
|
||||
return `<div class="stat"><span>${escapeHTML(label)}</span><strong>${escapeHTML(String(value ?? ""))}</strong></div>`;
|
||||
}
|
||||
|
||||
export function badge(text: string, off = false): string {
|
||||
return `<span class="badge ${off ? "off" : ""}">${escapeHTML(text)}</span>`;
|
||||
}
|
||||
|
||||
export function debounce<T extends (...args: unknown[]) => void>(fn: T, wait: number): T {
|
||||
let id = 0;
|
||||
return ((...args: Parameters<T>) => {
|
||||
clearTimeout(id);
|
||||
id = window.setTimeout(() => fn(...args), wait);
|
||||
}) as T;
|
||||
}
|
||||
|
||||
export function getFormInput(form: HTMLFormElement, name: string): HTMLInputElement {
|
||||
const field = form.elements.namedItem(name);
|
||||
if (!(field instanceof HTMLInputElement)) {
|
||||
throw new Error(`missing input ${name}`);
|
||||
}
|
||||
return field;
|
||||
}
|
||||
|
||||
export function getFormSelect(form: HTMLFormElement, name: string): HTMLSelectElement {
|
||||
const field = form.elements.namedItem(name);
|
||||
if (!(field instanceof HTMLSelectElement)) {
|
||||
throw new Error(`missing select ${name}`);
|
||||
}
|
||||
return field;
|
||||
}
|
||||
293
cmd/gateway/admin_frontend/src/i18n.ts
Normal file
293
cmd/gateway/admin_frontend/src/i18n.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { el } from "./dom.js";
|
||||
import { languageStorageKey, state } from "./state.js";
|
||||
|
||||
const defaultLanguage = "en";
|
||||
|
||||
const translations = {
|
||||
en: {
|
||||
activeConnections: "Active",
|
||||
activeUser: "Active",
|
||||
actions: "Actions",
|
||||
actor: "Actor",
|
||||
adminSubtitle: "Admin",
|
||||
audit: "Audit",
|
||||
cancel: "Cancel",
|
||||
createAdmin: "Create admin",
|
||||
dataShards: "Data shards",
|
||||
delete: "Delete",
|
||||
deleteDefaultRouteConfirm: "Delete default route?",
|
||||
deleteUserConfirm: "Delete user {username}?",
|
||||
dialErrors: "Dial errors",
|
||||
disabled: "Disabled",
|
||||
edit: "Edit",
|
||||
enabled: "Enabled",
|
||||
failed: "Failed",
|
||||
host: "Host",
|
||||
initialAdmin: "Initial admin",
|
||||
language: "Language",
|
||||
languageChinese: "中文",
|
||||
languageEnglish: "English",
|
||||
login: "Login",
|
||||
logout: "Logout",
|
||||
message: "Message",
|
||||
metrics: "Metrics",
|
||||
misses: "Misses",
|
||||
newRoute: "New route",
|
||||
newUser: "New user",
|
||||
noHits: "No hits",
|
||||
note: "Note",
|
||||
parityShards: "Parity shards",
|
||||
password: "Password",
|
||||
path: "Path",
|
||||
port: "Port",
|
||||
protocols: "Protocols",
|
||||
restart: "Restart",
|
||||
restartRequired: "restart required",
|
||||
result: "Result",
|
||||
role: "Role",
|
||||
roleAdmin: "Admin",
|
||||
roleGuest: "Guest",
|
||||
roleMember: "Member",
|
||||
route: "Route",
|
||||
routeHits: "Route hits",
|
||||
routeSearch: "Search host, upstream, note",
|
||||
routes: "Routes",
|
||||
save: "Save",
|
||||
services: "Services",
|
||||
setupSubtitle: "Setup",
|
||||
success: "Success",
|
||||
target: "Target",
|
||||
time: "Time",
|
||||
total: "Total",
|
||||
upstream: "Upstream",
|
||||
uptime: "Uptime",
|
||||
user: "User",
|
||||
username: "Username",
|
||||
users: "Users",
|
||||
},
|
||||
zh: {
|
||||
activeConnections: "活跃连接",
|
||||
activeUser: "启用",
|
||||
actions: "操作",
|
||||
actor: "操作者",
|
||||
adminSubtitle: "管理后台",
|
||||
audit: "审计",
|
||||
cancel: "取消",
|
||||
createAdmin: "创建管理员",
|
||||
dataShards: "数据分片",
|
||||
delete: "删除",
|
||||
deleteDefaultRouteConfirm: "确认删除默认路由?",
|
||||
deleteUserConfirm: "确认删除用户 {username}?",
|
||||
dialErrors: "连接上游失败",
|
||||
disabled: "禁用",
|
||||
edit: "编辑",
|
||||
enabled: "启用",
|
||||
failed: "失败",
|
||||
host: "主机",
|
||||
initialAdmin: "初始化管理员",
|
||||
language: "语言",
|
||||
languageChinese: "中文",
|
||||
languageEnglish: "English",
|
||||
login: "登录",
|
||||
logout: "退出登录",
|
||||
message: "消息",
|
||||
metrics: "指标",
|
||||
misses: "未命中",
|
||||
newRoute: "新建路由",
|
||||
newUser: "新建用户",
|
||||
noHits: "暂无命中",
|
||||
note: "备注",
|
||||
parityShards: "校验分片",
|
||||
password: "密码",
|
||||
path: "路径",
|
||||
port: "端口",
|
||||
protocols: "协议",
|
||||
restart: "重启",
|
||||
restartRequired: "需要重启",
|
||||
result: "结果",
|
||||
role: "角色",
|
||||
roleAdmin: "管理员",
|
||||
roleGuest: "访客",
|
||||
roleMember: "成员",
|
||||
route: "路由",
|
||||
routeHits: "路由命中",
|
||||
routeSearch: "搜索主机、上游、备注",
|
||||
routes: "路由",
|
||||
save: "保存",
|
||||
services: "服务",
|
||||
setupSubtitle: "初始化",
|
||||
success: "成功",
|
||||
target: "目标",
|
||||
time: "时间",
|
||||
total: "总数",
|
||||
upstream: "上游",
|
||||
uptime: "运行时间",
|
||||
user: "用户",
|
||||
username: "用户名",
|
||||
users: "用户",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type TranslationKey = keyof typeof translations.en;
|
||||
type Language = keyof typeof translations;
|
||||
|
||||
const localizedMessages: Partial<Record<Language, Record<string, string>>> = {
|
||||
zh: {
|
||||
"admin API prefix cannot be under static asset path": "管理 API 前缀不能位于静态资源路径下",
|
||||
"admin API prefix cannot equal admin page path": "管理 API 前缀不能等于管理页面路径",
|
||||
"cannot delete the last enabled admin": "不能删除最后一个启用的管理员",
|
||||
"cannot remove the last enabled admin": "不能移除最后一个启用的管理员",
|
||||
"created initial admin": "已创建初始管理员",
|
||||
"host is required": "主机不能为空",
|
||||
"host must not contain /": "主机不能包含 /",
|
||||
"host must not contain whitespace": "主机不能包含空白字符",
|
||||
"initial admin has already been created": "初始管理员已创建",
|
||||
"invalid JSON": "JSON 无效",
|
||||
"invalid path segment": "路径片段无效",
|
||||
"invalid username or password": "用户名或密码无效",
|
||||
"login required": "需要登录",
|
||||
"login success": "登录成功",
|
||||
"logout success": "退出登录成功",
|
||||
"method not allowed": "方法不允许",
|
||||
"not found": "未找到",
|
||||
"password hash is required": "密码哈希不能为空",
|
||||
"password is required": "密码不能为空",
|
||||
"permission denied": "没有权限",
|
||||
"port must be an integer from 1 to 65535": "端口必须是 1 到 65535 之间的整数",
|
||||
"restart is not implemented": "暂未实现重启",
|
||||
"route deleted": "路由已删除",
|
||||
"route saved": "路由已保存",
|
||||
"service restart is not implemented; restart the gateway process": "暂未实现服务重启;请重启网关进程",
|
||||
"service saved": "服务已保存",
|
||||
"tcp_admin cannot be disabled": "tcp_admin 不能被禁用",
|
||||
"upstream host is required": "上游主机不能为空",
|
||||
"upstream is required": "上游不能为空",
|
||||
"upstream must be host:port": "上游必须是 host:port 格式",
|
||||
"upstream port must be an integer from 1 to 65535": "上游端口必须是 1 到 65535 之间的整数",
|
||||
"user created": "用户已创建",
|
||||
"user deleted": "用户已删除",
|
||||
"user is disabled": "用户已禁用",
|
||||
"user not found": "用户不存在",
|
||||
"user updated": "用户已更新",
|
||||
"username is required": "用户名不能为空",
|
||||
"username must not contain whitespace or /": "用户名不能包含空白字符或 /",
|
||||
},
|
||||
};
|
||||
|
||||
const localizedPrefixes: Partial<Record<Language, Array<[string, string]>>> = {
|
||||
zh: [
|
||||
["invalid JSON: ", "JSON 无效:"],
|
||||
["invalid role ", "无效角色 "],
|
||||
["unknown service ", "未知服务 "],
|
||||
],
|
||||
};
|
||||
|
||||
export const auditActions: Partial<Record<Language, Record<string, string>>> = {
|
||||
zh: {
|
||||
login: "登录",
|
||||
logout: "退出登录",
|
||||
route_delete: "删除路由",
|
||||
route_upsert: "保存路由",
|
||||
service_restart: "重启服务",
|
||||
service_update: "更新服务",
|
||||
setup: "初始化",
|
||||
user_create: "创建用户",
|
||||
user_delete: "删除用户",
|
||||
user_patch: "更新用户",
|
||||
},
|
||||
};
|
||||
|
||||
export const targetTypes: Partial<Record<Language, Record<string, string>>> = {
|
||||
zh: {
|
||||
route: "路由",
|
||||
service: "服务",
|
||||
session: "会话",
|
||||
user: "用户",
|
||||
},
|
||||
};
|
||||
|
||||
export function initializeLanguage(): void {
|
||||
state.language = resolveInitialLanguage();
|
||||
applyLanguage();
|
||||
}
|
||||
|
||||
export function changeLanguage(next: string, rerender: () => void): void {
|
||||
if (!isLanguage(next) || next === state.language) {
|
||||
return;
|
||||
}
|
||||
state.language = next;
|
||||
localStorage.setItem(languageStorageKey, next);
|
||||
applyLanguage();
|
||||
rerender();
|
||||
}
|
||||
|
||||
export function applyLanguage(): void {
|
||||
document.documentElement.lang = state.language === "zh" ? "zh-CN" : "en";
|
||||
el<HTMLSelectElement>("languageSelect").value = state.language;
|
||||
document.querySelectorAll<HTMLElement>("[data-i18n]").forEach((node) => {
|
||||
node.textContent = t(node.dataset.i18n as TranslationKey);
|
||||
});
|
||||
document.querySelectorAll<HTMLInputElement>("[data-i18n-placeholder]").forEach((node) => {
|
||||
node.placeholder = t(node.dataset.i18nPlaceholder as TranslationKey);
|
||||
});
|
||||
if (!el("setupView").classList.contains("hidden")) {
|
||||
setSubtitle("setupSubtitle");
|
||||
} else if (!el("loginView").classList.contains("hidden")) {
|
||||
setSubtitle("login");
|
||||
} else {
|
||||
setSubtitle("adminSubtitle");
|
||||
}
|
||||
}
|
||||
|
||||
export function setSubtitle(key: TranslationKey): void {
|
||||
el("subtitle").textContent = t(key);
|
||||
}
|
||||
|
||||
export function t(key: TranslationKey, values: Record<string, string> = {}): string {
|
||||
const lang = isLanguage(state.language) ? state.language : defaultLanguage;
|
||||
let text: string = translations[lang][key] || translations[defaultLanguage][key] || key;
|
||||
for (const [name, value] of Object.entries(values)) {
|
||||
text = text.replaceAll(`{${name}}`, value);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export function localizeMessage(message: unknown): string {
|
||||
if (!message) {
|
||||
return "";
|
||||
}
|
||||
const lang = isLanguage(state.language) ? state.language : defaultLanguage;
|
||||
const text = String(message);
|
||||
const messages = localizedMessages[lang] || {};
|
||||
if (messages[text]) {
|
||||
return messages[text];
|
||||
}
|
||||
const prefixes = localizedPrefixes[lang] || [];
|
||||
for (const [source, replacement] of prefixes) {
|
||||
if (text.startsWith(source)) {
|
||||
return replacement + text.slice(source.length);
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export function formatAuditAction(action: string): string {
|
||||
return auditActions[state.language as Language]?.[action] || action;
|
||||
}
|
||||
|
||||
export function formatTargetType(targetType: string): string {
|
||||
return targetTypes[state.language as Language]?.[targetType] || targetType;
|
||||
}
|
||||
|
||||
function resolveInitialLanguage(): Language {
|
||||
const stored = localStorage.getItem(languageStorageKey);
|
||||
if (isLanguage(stored)) {
|
||||
return stored;
|
||||
}
|
||||
const browserLanguage = (navigator.language || "").toLowerCase();
|
||||
return browserLanguage.startsWith("zh") ? "zh" : defaultLanguage;
|
||||
}
|
||||
|
||||
function isLanguage(value: unknown): value is Language {
|
||||
return typeof value === "string" && value in translations;
|
||||
}
|
||||
188
cmd/gateway/admin_frontend/src/main.ts
Normal file
188
cmd/gateway/admin_frontend/src/main.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { api } from "./api.js";
|
||||
import { showAlert } from "./alerts.js";
|
||||
import { runtimeConfig } from "./config.js";
|
||||
import { debounce, el } from "./dom.js";
|
||||
import { changeLanguage, initializeLanguage, setSubtitle } from "./i18n.js";
|
||||
import { isAdmin, isMember, renderSessionUser } from "./session.js";
|
||||
import { setToken, state } from "./state.js";
|
||||
import type { LoginResponse, SetupStatus, User } from "./types.js";
|
||||
import { loadAudit } from "./views/audit.js";
|
||||
import { loadMetrics } from "./views/metrics.js";
|
||||
import { loadRoutes, openRouteDialog, renderRoutes, saveRoute } from "./views/routes.js";
|
||||
import { loadServices, renderServices } from "./views/services.js";
|
||||
import { loadStatus } from "./views/status.js";
|
||||
import { loadUsers, openUserDialog, renderUsers, saveUser } from "./views/users.js";
|
||||
|
||||
async function boot(): Promise<void> {
|
||||
state.apiBase = runtimeConfig().apiPrefix;
|
||||
initializeLanguage();
|
||||
bindEvents();
|
||||
try {
|
||||
const setup = await api<SetupStatus>("/setup");
|
||||
if (setup.required) {
|
||||
setView("setupView");
|
||||
setSubtitle("setupSubtitle");
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
|
||||
if (!state.token) {
|
||||
setView("loginView");
|
||||
setSubtitle("login");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
state.user = await api<User>("/me");
|
||||
await showApp();
|
||||
} catch {
|
||||
setToken("");
|
||||
setView("loginView");
|
||||
setSubtitle("login");
|
||||
}
|
||||
}
|
||||
|
||||
function bindEvents(): void {
|
||||
el<HTMLSelectElement>("languageSelect").addEventListener("change", (event) => {
|
||||
changeLanguage((event.currentTarget as HTMLSelectElement).value, rerenderCurrentView);
|
||||
});
|
||||
el<HTMLFormElement>("setupForm").addEventListener("submit", submitSetup);
|
||||
el<HTMLFormElement>("loginForm").addEventListener("submit", submitLogin);
|
||||
el("logoutBtn").addEventListener("click", logout);
|
||||
el("routeSearch").addEventListener("input", debounce(loadRoutes, 180));
|
||||
el("newRouteBtn").addEventListener("click", () => openRouteDialog());
|
||||
el("newUserBtn").addEventListener("click", () => openUserDialog());
|
||||
el<HTMLFormElement>("routeForm").addEventListener("submit", saveRoute);
|
||||
el<HTMLFormElement>("userForm").addEventListener("submit", saveUser);
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-close]").forEach((button) => {
|
||||
button.addEventListener("click", () => button.closest("dialog")?.close());
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>(".tabs button").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
if (button.dataset.tab) {
|
||||
selectTab(button.dataset.tab);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function submitSetup(event: SubmitEvent): Promise<void> {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget as HTMLFormElement);
|
||||
try {
|
||||
await api("/setup", {
|
||||
method: "POST",
|
||||
body: {
|
||||
username: form.get("username"),
|
||||
password: form.get("password"),
|
||||
},
|
||||
});
|
||||
showAlert("");
|
||||
setView("loginView");
|
||||
setSubtitle("login");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitLogin(event: SubmitEvent): Promise<void> {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget as HTMLFormElement);
|
||||
try {
|
||||
const data = await api<LoginResponse>("/auth/login", {
|
||||
method: "POST",
|
||||
body: {
|
||||
username: form.get("username"),
|
||||
password: form.get("password"),
|
||||
},
|
||||
});
|
||||
setToken(data.token);
|
||||
state.user = data.user;
|
||||
showAlert("");
|
||||
await showApp();
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
try {
|
||||
await api("/auth/logout", { method: "POST", body: {} });
|
||||
} catch {
|
||||
}
|
||||
setToken("");
|
||||
state.user = null;
|
||||
setView("loginView");
|
||||
setSubtitle("login");
|
||||
renderSessionUser();
|
||||
el("logoutBtn").classList.add("hidden");
|
||||
}
|
||||
|
||||
async function showApp(): Promise<void> {
|
||||
setView("appView");
|
||||
setSubtitle("adminSubtitle");
|
||||
renderSessionUser();
|
||||
el("logoutBtn").classList.remove("hidden");
|
||||
applyRoleVisibility();
|
||||
await loadRoutes();
|
||||
if (isMember()) {
|
||||
await loadStatus();
|
||||
await loadServices();
|
||||
await loadMetrics();
|
||||
}
|
||||
if (isAdmin()) {
|
||||
await loadUsers();
|
||||
await loadAudit();
|
||||
}
|
||||
}
|
||||
|
||||
function applyRoleVisibility(): void {
|
||||
const member = isMember();
|
||||
const admin = isAdmin();
|
||||
el("statusGrid").classList.toggle("hidden", !member);
|
||||
el("newRouteBtn").classList.toggle("hidden", !member);
|
||||
toggleTab("services", member);
|
||||
toggleTab("metrics", member);
|
||||
toggleTab("users", admin);
|
||||
toggleTab("audit", admin);
|
||||
selectTab("routes");
|
||||
}
|
||||
|
||||
function toggleTab(name: string, visible: boolean): void {
|
||||
document.querySelector(`[data-tab="${name}"]`)?.classList.toggle("hidden", !visible);
|
||||
}
|
||||
|
||||
function selectTab(name: string): void {
|
||||
document.querySelectorAll<HTMLButtonElement>(".tabs button").forEach((button) => {
|
||||
button.classList.toggle("active", button.dataset.tab === name);
|
||||
});
|
||||
document.querySelectorAll<HTMLElement>(".tab-panel").forEach((panel) => {
|
||||
panel.classList.add("hidden");
|
||||
});
|
||||
el(`${name}Tab`).classList.remove("hidden");
|
||||
}
|
||||
|
||||
function setView(name: string): void {
|
||||
for (const id of ["setupView", "loginView", "appView"]) {
|
||||
el(id).classList.toggle("hidden", id !== name);
|
||||
}
|
||||
}
|
||||
|
||||
function rerenderCurrentView(): void {
|
||||
renderSessionUser();
|
||||
renderRoutes();
|
||||
renderServices();
|
||||
renderUsers();
|
||||
if (isMember()) {
|
||||
loadStatus();
|
||||
loadMetrics();
|
||||
}
|
||||
if (isAdmin()) {
|
||||
loadAudit();
|
||||
}
|
||||
}
|
||||
|
||||
boot();
|
||||
26
cmd/gateway/admin_frontend/src/session.ts
Normal file
26
cmd/gateway/admin_frontend/src/session.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { el } from "./dom.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { state } from "./state.js";
|
||||
import type { Role } from "./types.js";
|
||||
|
||||
export function isAdmin(): boolean {
|
||||
return state.user?.role === "admin";
|
||||
}
|
||||
|
||||
export function isMember(): boolean {
|
||||
return state.user?.role === "admin" || state.user?.role === "member";
|
||||
}
|
||||
|
||||
export function renderSessionUser(): void {
|
||||
el("sessionUser").textContent = state.user ? `${state.user.username} (${formatRole(state.user.role)})` : "";
|
||||
}
|
||||
|
||||
export function formatRole(role: Role | string): string {
|
||||
const keys: Partial<Record<string, "roleAdmin" | "roleMember" | "roleGuest">> = {
|
||||
admin: "roleAdmin",
|
||||
member: "roleMember",
|
||||
guest: "roleGuest",
|
||||
};
|
||||
const key = keys[role];
|
||||
return key ? t(key) : role;
|
||||
}
|
||||
33
cmd/gateway/admin_frontend/src/state.ts
Normal file
33
cmd/gateway/admin_frontend/src/state.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { RouteRecord, ServiceRecord, User } from "./types.js";
|
||||
|
||||
export const tokenStorageKey = "mcGatewayAdminToken";
|
||||
export const languageStorageKey = "mcGatewayAdminLanguage";
|
||||
|
||||
export interface AppState {
|
||||
apiBase: string;
|
||||
language: string;
|
||||
token: string;
|
||||
user: User | null;
|
||||
routes: RouteRecord[];
|
||||
services: ServiceRecord[];
|
||||
users: User[];
|
||||
}
|
||||
|
||||
export const state: AppState = {
|
||||
apiBase: "/admin/api",
|
||||
language: "",
|
||||
token: sessionStorage.getItem(tokenStorageKey) || "",
|
||||
user: null,
|
||||
routes: [],
|
||||
services: [],
|
||||
users: [],
|
||||
};
|
||||
|
||||
export function setToken(token: string): void {
|
||||
state.token = token;
|
||||
if (token) {
|
||||
sessionStorage.setItem(tokenStorageKey, token);
|
||||
} else {
|
||||
sessionStorage.removeItem(tokenStorageKey);
|
||||
}
|
||||
}
|
||||
55
cmd/gateway/admin_frontend/src/types.d.ts
vendored
Normal file
55
cmd/gateway/admin_frontend/src/types.d.ts
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
export type Role = "admin" | "member" | "guest";
|
||||
|
||||
export interface User {
|
||||
username: string;
|
||||
role: Role;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export interface RouteRecord {
|
||||
host: string;
|
||||
upstream: string;
|
||||
enabled: boolean;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface ServiceRecord {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
options?: Record<string, unknown>;
|
||||
restart_required?: boolean;
|
||||
}
|
||||
|
||||
export interface Metrics {
|
||||
total_connections?: number;
|
||||
active_connections?: number;
|
||||
tcp_connections?: number;
|
||||
websocket_connections?: number;
|
||||
route_misses?: number;
|
||||
upstream_dial_errors?: number;
|
||||
route_hits?: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface AuditLog {
|
||||
created_at: number;
|
||||
actor: string;
|
||||
action: string;
|
||||
target_type: string;
|
||||
target_id: string;
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface SetupStatus {
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface RuntimeConfig {
|
||||
apiPrefix: string;
|
||||
}
|
||||
27
cmd/gateway/admin_frontend/src/views/audit.ts
Normal file
27
cmd/gateway/admin_frontend/src/views/audit.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { api } from "../api.js";
|
||||
import { showAlert } from "../alerts.js";
|
||||
import { badge, el, escapeHTML } from "../dom.js";
|
||||
import { formatAuditAction, formatTargetType, localizeMessage, t } from "../i18n.js";
|
||||
import type { AuditLog } from "../types.js";
|
||||
|
||||
interface AuditResponse {
|
||||
audit_logs?: AuditLog[];
|
||||
}
|
||||
|
||||
export async function loadAudit(): Promise<void> {
|
||||
try {
|
||||
const data = await api<AuditResponse>("/audit-logs");
|
||||
el("auditBody").innerHTML = (data.audit_logs || []).map((item) => `
|
||||
<tr>
|
||||
<td>${new Date(item.created_at * 1000).toLocaleString()}</td>
|
||||
<td>${escapeHTML(item.actor)}</td>
|
||||
<td>${escapeHTML(formatAuditAction(item.action))}</td>
|
||||
<td>${escapeHTML(formatTargetType(item.target_type))}:${escapeHTML(item.target_id)}</td>
|
||||
<td>${badge(item.success ? t("success") : t("failed"), !item.success)}</td>
|
||||
<td>${escapeHTML(localizeMessage(item.message || ""))}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
25
cmd/gateway/admin_frontend/src/views/metrics.ts
Normal file
25
cmd/gateway/admin_frontend/src/views/metrics.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { api } from "../api.js";
|
||||
import { showAlert } from "../alerts.js";
|
||||
import { el, escapeHTML, stat } from "../dom.js";
|
||||
import { t } from "../i18n.js";
|
||||
import type { Metrics } from "../types.js";
|
||||
|
||||
export async function loadMetrics(): Promise<void> {
|
||||
try {
|
||||
const data = await api<Metrics>("/metrics");
|
||||
el("metricsGrid").innerHTML = [
|
||||
stat(t("total"), data.total_connections),
|
||||
stat(t("activeConnections"), data.active_connections),
|
||||
stat("TCP", data.tcp_connections),
|
||||
stat("WebSocket", data.websocket_connections),
|
||||
stat(t("misses"), data.route_misses),
|
||||
stat(t("dialErrors"), data.upstream_dial_errors),
|
||||
].join("");
|
||||
const hits = data.route_hits || {};
|
||||
el("routeHits").innerHTML = Object.keys(hits).length
|
||||
? Object.entries(hits).map(([host, count]) => `<span class="chip">${escapeHTML(host)}: ${count}</span>`).join("")
|
||||
: `<span class="chip">${escapeHTML(t("noHits"))}</span>`;
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
108
cmd/gateway/admin_frontend/src/views/routes.ts
Normal file
108
cmd/gateway/admin_frontend/src/views/routes.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { api } from "../api.js";
|
||||
import { showAlert } from "../alerts.js";
|
||||
import { badge, el, escapeAttr, escapeHTML, getFormInput } from "../dom.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { isMember } from "../session.js";
|
||||
import { state } from "../state.js";
|
||||
import type { RouteRecord } from "../types.js";
|
||||
|
||||
interface RoutesResponse {
|
||||
routes?: RouteRecord[];
|
||||
}
|
||||
|
||||
export async function loadRoutes(): Promise<void> {
|
||||
try {
|
||||
const q = encodeURIComponent(el<HTMLInputElement>("routeSearch").value || "");
|
||||
const data = await api<RoutesResponse>(q ? `/routes?q=${q}` : "/routes");
|
||||
state.routes = data.routes || [];
|
||||
renderRoutes();
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderRoutes(): void {
|
||||
const canWrite = isMember();
|
||||
el("routesBody").innerHTML = state.routes.map((route) => `
|
||||
<tr>
|
||||
<td>${escapeHTML(route.host)}</td>
|
||||
<td>${escapeHTML(route.upstream)}</td>
|
||||
<td>${badge(route.enabled ? t("enabled") : t("disabled"), !route.enabled)}</td>
|
||||
<td>${escapeHTML(route.note || "")}</td>
|
||||
<td class="actions">${canWrite ? routeActions(route) : ""}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-edit-route]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const route = state.routes.find((item) => item.host === button.dataset.editRoute);
|
||||
openRouteDialog(route || null);
|
||||
});
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-delete-route]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const host = button.dataset.deleteRoute;
|
||||
if (host) {
|
||||
removeRoute(host);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function openRouteDialog(route: RouteRecord | null = null): void {
|
||||
const form = el<HTMLFormElement>("routeForm");
|
||||
form.reset();
|
||||
form.dataset.originalHost = route ? route.host : "";
|
||||
getFormInput(form, "host").disabled = Boolean(route);
|
||||
if (route) {
|
||||
getFormInput(form, "host").value = route.host;
|
||||
getFormInput(form, "upstream").value = route.upstream;
|
||||
getFormInput(form, "enabled").checked = route.enabled;
|
||||
getFormInput(form, "note").value = route.note || "";
|
||||
} else {
|
||||
getFormInput(form, "enabled").checked = true;
|
||||
}
|
||||
el<HTMLDialogElement>("routeDialog").showModal();
|
||||
}
|
||||
|
||||
export async function saveRoute(event: SubmitEvent): Promise<void> {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget as HTMLFormElement;
|
||||
const host = form.dataset.originalHost || getFormInput(form, "host").value;
|
||||
try {
|
||||
await api(`/routes/${encodeURIComponent(host)}`, {
|
||||
method: "PUT",
|
||||
body: {
|
||||
upstream: getFormInput(form, "upstream").value,
|
||||
enabled: getFormInput(form, "enabled").checked,
|
||||
note: getFormInput(form, "note").value,
|
||||
},
|
||||
});
|
||||
el<HTMLDialogElement>("routeDialog").close();
|
||||
await loadRoutes();
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRoute(host: string): Promise<void> {
|
||||
if (host === "default" && !confirm(t("deleteDefaultRouteConfirm"))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api(`/routes/${encodeURIComponent(host)}`, { method: "DELETE" });
|
||||
await loadRoutes();
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
function routeActions(route: RouteRecord): string {
|
||||
return `
|
||||
<div class="row-actions">
|
||||
<button class="secondary" type="button" data-edit-route="${escapeAttr(route.host)}">${escapeHTML(t("edit"))}</button>
|
||||
<button class="danger" type="button" data-delete-route="${escapeAttr(route.host)}">${escapeHTML(t("delete"))}</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
161
cmd/gateway/admin_frontend/src/views/services.ts
Normal file
161
cmd/gateway/admin_frontend/src/views/services.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { api } from "../api.js";
|
||||
import { showAlert } from "../alerts.js";
|
||||
import { el, escapeAttr, escapeHTML, getFormInput } from "../dom.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { isAdmin } from "../session.js";
|
||||
import { state } from "../state.js";
|
||||
import type { ServiceRecord } from "../types.js";
|
||||
|
||||
interface ServicesResponse {
|
||||
services?: ServiceRecord[];
|
||||
}
|
||||
|
||||
const serviceNames: Record<string, string> = {
|
||||
tcp_admin: "TCP/Admin",
|
||||
kcp: "KCP",
|
||||
quic: "QUIC",
|
||||
websocket: "WebSocket",
|
||||
};
|
||||
|
||||
export async function loadServices(): Promise<void> {
|
||||
try {
|
||||
const data = await api<ServicesResponse>("/services");
|
||||
state.services = data.services || [];
|
||||
renderServices();
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderServices(): void {
|
||||
el("servicesGrid").innerHTML = state.services.map((service) => `
|
||||
<article class="service">
|
||||
<div>
|
||||
<span>${escapeHTML(formatServiceName(service.name))}</span>
|
||||
<strong>${serviceStatusText(service)}</strong>
|
||||
</div>
|
||||
${isAdmin() ? serviceForm(service) : serviceSummary(service)}
|
||||
</article>
|
||||
`).join("");
|
||||
|
||||
document.querySelectorAll<HTMLFormElement>("[data-service-form]").forEach((form) => {
|
||||
form.addEventListener("submit", saveService);
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-restart-service]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const name = button.dataset.restartService;
|
||||
if (name) {
|
||||
restartService(name);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function serviceSummary(service: ServiceRecord): string {
|
||||
return `<div><span>${escapeHTML(t("port"))}</span><strong>${service.port}</strong></div>`;
|
||||
}
|
||||
|
||||
function serviceForm(service: ServiceRecord): string {
|
||||
const disabled = service.name === "tcp_admin" ? "disabled" : "";
|
||||
const optionFields = serviceOptionFields(service);
|
||||
return `
|
||||
<form data-service-form="${escapeAttr(service.name)}">
|
||||
<label class="inline">
|
||||
<input name="enabled" type="checkbox" ${service.enabled ? "checked" : ""} ${disabled}>
|
||||
${escapeHTML(t("enabled"))}
|
||||
</label>
|
||||
<label>
|
||||
${escapeHTML(t("port"))}
|
||||
<input name="port" type="number" min="1" max="65535" value="${service.port}">
|
||||
</label>
|
||||
${optionFields}
|
||||
<div class="row-actions">
|
||||
<button type="submit">${escapeHTML(t("save"))}</button>
|
||||
<button class="secondary" type="button" data-restart-service="${escapeAttr(service.name)}">${escapeHTML(t("restart"))}</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
function serviceOptionFields(service: ServiceRecord): string {
|
||||
const options = service.options || {};
|
||||
if (service.name === "kcp") {
|
||||
return `
|
||||
<label>${escapeHTML(t("dataShards"))}<input name="data_shards" type="number" min="1" value="${numberOption(options, "data_shards", 10)}"></label>
|
||||
<label>${escapeHTML(t("parityShards"))}<input name="parity_shards" type="number" min="1" value="${numberOption(options, "parity_shards", 3)}"></label>
|
||||
`;
|
||||
}
|
||||
if (service.name === "quic") {
|
||||
const protocols = arrayOption(options, "application_protocols").join(",");
|
||||
return `<label>${escapeHTML(t("protocols"))}<input name="application_protocols" value="${escapeAttr(protocols)}"></label>`;
|
||||
}
|
||||
if (service.name === "websocket") {
|
||||
return `<label>${escapeHTML(t("path"))}<input name="path" value="${escapeAttr(stringOption(options, "path", "/"))}"></label>`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function saveService(event: SubmitEvent): Promise<void> {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget as HTMLFormElement;
|
||||
const name = form.dataset.serviceForm;
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
const options: Record<string, unknown> = {};
|
||||
if (name === "kcp") {
|
||||
options.data_shards = Number(getFormInput(form, "data_shards").value);
|
||||
options.parity_shards = Number(getFormInput(form, "parity_shards").value);
|
||||
} else if (name === "quic") {
|
||||
options.application_protocols = getFormInput(form, "application_protocols").value.split(",").map((item) => item.trim()).filter(Boolean);
|
||||
} else if (name === "websocket") {
|
||||
options.path = getFormInput(form, "path").value;
|
||||
}
|
||||
|
||||
try {
|
||||
await api(`/services/${encodeURIComponent(name)}`, {
|
||||
method: "PUT",
|
||||
body: {
|
||||
enabled: name === "tcp_admin" ? true : getFormInput(form, "enabled").checked,
|
||||
port: Number(getFormInput(form, "port").value),
|
||||
options,
|
||||
},
|
||||
});
|
||||
await loadServices();
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function restartService(name: string): Promise<void> {
|
||||
try {
|
||||
await api(`/services/${encodeURIComponent(name)}/restart`, { method: "POST", body: {} });
|
||||
await loadServices();
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
function formatServiceName(name: string): string {
|
||||
return serviceNames[name] || name;
|
||||
}
|
||||
|
||||
function serviceStatusText(service: ServiceRecord): string {
|
||||
const status = service.enabled ? t("enabled") : t("disabled");
|
||||
return escapeHTML(service.restart_required ? `${status} / ${t("restartRequired")}` : status);
|
||||
}
|
||||
|
||||
function numberOption(options: Record<string, unknown>, key: string, fallback: number): number {
|
||||
const value = options[key];
|
||||
return typeof value === "number" ? value : fallback;
|
||||
}
|
||||
|
||||
function stringOption(options: Record<string, unknown>, key: string, fallback: string): string {
|
||||
const value = options[key];
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function arrayOption(options: Record<string, unknown>, key: string): string[] {
|
||||
const value = options[key];
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
||||
}
|
||||
25
cmd/gateway/admin_frontend/src/views/status.ts
Normal file
25
cmd/gateway/admin_frontend/src/views/status.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { api } from "../api.js";
|
||||
import { showAlert } from "../alerts.js";
|
||||
import { el, stat } from "../dom.js";
|
||||
import { t } from "../i18n.js";
|
||||
|
||||
interface StatusResponse {
|
||||
pid?: number;
|
||||
uptime_seconds?: number;
|
||||
db_path?: string;
|
||||
tcp_admin_port?: number;
|
||||
}
|
||||
|
||||
export async function loadStatus(): Promise<void> {
|
||||
try {
|
||||
const status = await api<StatusResponse>("/status");
|
||||
el("statusGrid").innerHTML = [
|
||||
stat("PID", status.pid),
|
||||
stat(t("uptime"), `${status.uptime_seconds}s`),
|
||||
stat("SQLite", status.db_path),
|
||||
stat("TCP/Admin", status.tcp_admin_port),
|
||||
].join("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
106
cmd/gateway/admin_frontend/src/views/users.ts
Normal file
106
cmd/gateway/admin_frontend/src/views/users.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { api } from "../api.js";
|
||||
import { showAlert } from "../alerts.js";
|
||||
import { badge, el, escapeAttr, escapeHTML, getFormInput, getFormSelect } from "../dom.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { formatRole } from "../session.js";
|
||||
import { state } from "../state.js";
|
||||
import type { User } from "../types.js";
|
||||
|
||||
interface UsersResponse {
|
||||
users?: User[];
|
||||
}
|
||||
|
||||
export async function loadUsers(): Promise<void> {
|
||||
try {
|
||||
const data = await api<UsersResponse>("/users");
|
||||
state.users = data.users || [];
|
||||
renderUsers();
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderUsers(): void {
|
||||
el("usersBody").innerHTML = state.users.map((user) => `
|
||||
<tr>
|
||||
<td>${escapeHTML(user.username)}</td>
|
||||
<td>${escapeHTML(formatRole(user.role))}</td>
|
||||
<td>${badge(user.disabled ? t("disabled") : t("activeUser"), user.disabled)}</td>
|
||||
<td class="actions">
|
||||
<div class="row-actions">
|
||||
<button class="secondary" type="button" data-edit-user="${escapeAttr(user.username)}">${escapeHTML(t("edit"))}</button>
|
||||
<button class="danger" type="button" data-delete-user="${escapeAttr(user.username)}">${escapeHTML(t("delete"))}</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-edit-user]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const user = state.users.find((item) => item.username === button.dataset.editUser);
|
||||
openUserDialog(user || null);
|
||||
});
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-delete-user]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const username = button.dataset.deleteUser;
|
||||
if (username) {
|
||||
removeUser(username);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function openUserDialog(user: User | null = null): void {
|
||||
const form = el<HTMLFormElement>("userForm");
|
||||
form.reset();
|
||||
form.dataset.originalUsername = user ? user.username : "";
|
||||
getFormInput(form, "username").disabled = Boolean(user);
|
||||
getFormInput(form, "password").required = !user;
|
||||
if (user) {
|
||||
getFormInput(form, "username").value = user.username;
|
||||
getFormSelect(form, "role").value = user.role;
|
||||
getFormInput(form, "disabled").checked = user.disabled;
|
||||
} else {
|
||||
getFormSelect(form, "role").value = "member";
|
||||
}
|
||||
el<HTMLDialogElement>("userDialog").showModal();
|
||||
}
|
||||
|
||||
export async function saveUser(event: SubmitEvent): Promise<void> {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget as HTMLFormElement;
|
||||
const username = form.dataset.originalUsername || getFormInput(form, "username").value;
|
||||
const body: Record<string, unknown> = {
|
||||
role: getFormSelect(form, "role").value,
|
||||
disabled: getFormInput(form, "disabled").checked,
|
||||
};
|
||||
if (getFormInput(form, "password").value) {
|
||||
body.password = getFormInput(form, "password").value;
|
||||
}
|
||||
|
||||
try {
|
||||
if (form.dataset.originalUsername) {
|
||||
await api(`/users/${encodeURIComponent(username)}`, { method: "PATCH", body });
|
||||
} else {
|
||||
body.username = username;
|
||||
await api("/users", { method: "POST", body });
|
||||
}
|
||||
el<HTMLDialogElement>("userDialog").close();
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeUser(username: string): Promise<void> {
|
||||
if (!confirm(t("deleteUserConfirm", { username }))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api(`/users/${encodeURIComponent(username)}`, { method: "DELETE" });
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
275
cmd/gateway/admin_plugin_handlers.go
Normal file
275
cmd/gateway/admin_plugin_handlers.go
Normal file
@@ -0,0 +1,275 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/tursom/mc-gateway/internal/adminhttp"
|
||||
"github.com/tursom/mc-gateway/internal/pluginmanager"
|
||||
)
|
||||
|
||||
func handleAdminPluginArtifacts(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := requireRole(w, r, adminRoleMember)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if pluginsManager == nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusServiceUnavailable, "plugin manager is not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
artifacts, err := pluginsManager.ListArtifacts(r.Context(), r.URL.Query().Get("plugin_id"))
|
||||
if err != nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"artifacts": artifacts})
|
||||
case http.MethodPost:
|
||||
if session.Role != adminRoleAdmin {
|
||||
adminhttp.WriteAPIError(w, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
artifact, err := receivePluginArtifact(r, session.Username)
|
||||
if err != nil {
|
||||
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_artifact_upload", "plugin_artifact", "", false, err.Error())
|
||||
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_artifact_upload", "plugin_artifact", artifact.ID, true, "artifact uploaded", map[string]any{
|
||||
"plugin_id": artifact.PluginID,
|
||||
"version": artifact.Version,
|
||||
"sha256": artifact.SHA256,
|
||||
"package_sha256": artifact.PackageSHA256,
|
||||
})
|
||||
adminhttp.WriteJSON(w, http.StatusCreated, map[string]any{"artifact": artifact})
|
||||
default:
|
||||
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func handleAdminPluginArtifact(w http.ResponseWriter, r *http.Request, rawArtifactID string) {
|
||||
if _, ok := requireRole(w, r, adminRoleMember); !ok {
|
||||
return
|
||||
}
|
||||
if pluginsManager == nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusServiceUnavailable, "plugin manager is not initialized")
|
||||
return
|
||||
}
|
||||
artifactID, err := adminhttp.PathSegment(rawArtifactID)
|
||||
if err != nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
artifact, err := pluginsManager.Artifact(r.Context(), artifactID)
|
||||
if err != nil {
|
||||
writePluginManagerError(w, err)
|
||||
return
|
||||
}
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"artifact": artifact})
|
||||
}
|
||||
|
||||
func handleAdminPluginsList(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireRole(w, r, adminRoleMember); !ok {
|
||||
return
|
||||
}
|
||||
if pluginsManager == nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusServiceUnavailable, "plugin manager is not initialized")
|
||||
return
|
||||
}
|
||||
plugins, err := pluginsManager.ListPlugins(r.Context())
|
||||
if err != nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"plugins": plugins})
|
||||
}
|
||||
|
||||
func handleAdminPluginItem(w http.ResponseWriter, r *http.Request, rawPluginID string) {
|
||||
session, ok := requireRole(w, r, adminRoleMember)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if pluginsManager == nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusServiceUnavailable, "plugin manager is not initialized")
|
||||
return
|
||||
}
|
||||
pluginID, err := adminhttp.PathSegment(rawPluginID)
|
||||
if err != nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
plugin, err := pluginsManager.Plugin(r.Context(), pluginID)
|
||||
if err != nil {
|
||||
writePluginManagerError(w, err)
|
||||
return
|
||||
}
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"plugin": plugin})
|
||||
case http.MethodPut:
|
||||
if session.Role != adminRoleAdmin {
|
||||
adminhttp.WriteAPIError(w, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
var req adminhttp.PluginDesiredRequest
|
||||
if !adminhttp.DecodeJSONRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
configJSON, err := pluginConfigJSON(req)
|
||||
if err != nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
plugin, err := pluginsManager.SetDesired(r.Context(), session.Username, pluginID, req.ArtifactID, req.DesiredState, configJSON, req.Priority)
|
||||
if err != nil {
|
||||
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_desired_update", "plugin", pluginID, false, err.Error())
|
||||
writePluginManagerError(w, err)
|
||||
return
|
||||
}
|
||||
recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_desired_update", "plugin", pluginID, true, "desired state updated", map[string]any{
|
||||
"artifact_id": req.ArtifactID,
|
||||
"desired_state": plugin.DesiredState,
|
||||
"desired_generation": plugin.DesiredGeneration,
|
||||
"priority": plugin.Priority,
|
||||
})
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"plugin": plugin})
|
||||
default:
|
||||
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func handleAdminPluginAction(w http.ResponseWriter, r *http.Request, rawSegment string) {
|
||||
session, ok := requireRole(w, r, adminRoleAdmin)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if pluginsManager == nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusServiceUnavailable, "plugin manager is not initialized")
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
parts := strings.Split(rawSegment, "/")
|
||||
if len(parts) != 2 {
|
||||
adminhttp.WriteAPIError(w, http.StatusBadRequest, "invalid plugin action")
|
||||
return
|
||||
}
|
||||
pluginID, err := adminhttp.PathSegment(parts[0])
|
||||
if err != nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
action, err := adminhttp.PathSegment(parts[1])
|
||||
if err != nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var plugin pluginmanager.PluginRecord
|
||||
switch action {
|
||||
case "load":
|
||||
plugin, err = pluginsManager.Load(r.Context(), session.Username, pluginID)
|
||||
case "enable":
|
||||
plugin, err = pluginsManager.Enable(r.Context(), session.Username, pluginID)
|
||||
case "disable":
|
||||
plugin, err = pluginsManager.Disable(r.Context(), session.Username, pluginID)
|
||||
case "delete":
|
||||
err = pluginsManager.Delete(r.Context(), session.Username, pluginID)
|
||||
default:
|
||||
adminhttp.WriteAPIError(w, http.StatusBadRequest, "unknown plugin action")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_"+action, "plugin", pluginID, false, err.Error())
|
||||
writePluginManagerError(w, err)
|
||||
return
|
||||
}
|
||||
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_"+action, "plugin", pluginID, true, "plugin "+action+" succeeded")
|
||||
if action == "delete" {
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"plugin": plugin})
|
||||
}
|
||||
|
||||
func handleAdminPluginDispatchPlan(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireRole(w, r, adminRoleMember); !ok {
|
||||
return
|
||||
}
|
||||
if pluginsManager == nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusServiceUnavailable, "plugin manager is not initialized")
|
||||
return
|
||||
}
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"dispatch_plan": pluginsManager.DispatchPlan(r.Context())})
|
||||
}
|
||||
|
||||
func receivePluginArtifact(r *http.Request, actor string) (pluginmanager.ArtifactRecord, error) {
|
||||
if err := r.ParseMultipartForm(64 << 20); err != nil {
|
||||
return pluginmanager.ArtifactRecord{}, err
|
||||
}
|
||||
file, header, err := r.FormFile("artifact")
|
||||
if err != nil {
|
||||
return pluginmanager.ArtifactRecord{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
tmp, err := os.CreateTemp("", "mc-gateway-plugin-*.mcgp")
|
||||
if err != nil {
|
||||
return pluginmanager.ArtifactRecord{}, err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
defer tmp.Close()
|
||||
|
||||
if _, err := tmp.ReadFrom(file); err != nil {
|
||||
return pluginmanager.ArtifactRecord{}, err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return pluginmanager.ArtifactRecord{}, err
|
||||
}
|
||||
|
||||
return pluginsManager.UploadArtifact(r.Context(), pluginmanager.ArtifactUpload{
|
||||
SourcePath: tmpPath,
|
||||
FileName: filepath.Base(header.Filename),
|
||||
Actor: actor,
|
||||
})
|
||||
}
|
||||
|
||||
func pluginConfigJSON(req adminhttp.PluginDesiredRequest) (string, error) {
|
||||
if strings.TrimSpace(req.ConfigJSON) != "" {
|
||||
if !json.Valid([]byte(req.ConfigJSON)) {
|
||||
return "", errors.New("config_json must be valid JSON")
|
||||
}
|
||||
return req.ConfigJSON, nil
|
||||
}
|
||||
if req.Config == nil {
|
||||
return "{}", nil
|
||||
}
|
||||
data, err := json.Marshal(req.Config)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func writePluginManagerError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, pluginmanager.ErrArtifactNotFound), errors.Is(err, pluginmanager.ErrPluginNotFound):
|
||||
adminhttp.WriteAPIError(w, http.StatusNotFound, err.Error())
|
||||
default:
|
||||
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,13 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/tursom/mc-gateway/internal/adminconfig"
|
||||
"github.com/tursom/mc-gateway/internal/admindb"
|
||||
"github.com/tursom/mc-gateway/internal/adminservice"
|
||||
"github.com/tursom/mc-gateway/internal/pluginmanager"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -44,6 +46,7 @@ var (
|
||||
|
||||
adminDB *sql.DB
|
||||
adminDBPath string
|
||||
pluginsManager *pluginmanager.Manager
|
||||
processStartAt = time.Now()
|
||||
)
|
||||
|
||||
@@ -77,7 +80,17 @@ func initializeGatewayRuntime() error {
|
||||
if err := ensureInitialAdminFromEnv(context.Background(), db, os.Getenv(adminEnvInitialPassword)); err != nil {
|
||||
return err
|
||||
}
|
||||
return refreshRouteSnapshot(context.Background())
|
||||
if err := refreshRouteSnapshot(context.Background()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pluginsManager = pluginmanager.New(pluginmanager.Options{
|
||||
DB: db,
|
||||
ArtifactRoot: filepath.Join(filepath.Dir(startup.DBPath), "plugins", "artifacts"),
|
||||
HandleConn: handleRequest,
|
||||
WaitGroup: &exitWaitGroup,
|
||||
})
|
||||
return pluginsManager.Reconcile(context.Background())
|
||||
}
|
||||
|
||||
func closeGatewayRuntime() {
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"net/http"
|
||||
|
||||
"github.com/tursom/mc-gateway/internal/adminhttp"
|
||||
)
|
||||
|
||||
//go:embed admin_static/index.html admin_static/app.css admin_static/app.js
|
||||
var adminStaticFS embed.FS
|
||||
|
||||
func newGatewayHTTPHandler() http.Handler {
|
||||
return adminhttp.NewGatewayHandler(adminhttp.GatewayHandlerOptions{
|
||||
AdminPath: adminStartup.AdminPath,
|
||||
AdminAPIPrefix: adminStartup.AdminAPIPrefix,
|
||||
Assets: adminStaticFS,
|
||||
APIHandler: newAdminAPIHandler(),
|
||||
WebSocketEnabled: config.WebSocket.Enable,
|
||||
WebSocketPath: normalizedWebSocketPath(),
|
||||
|
||||
@@ -126,6 +126,18 @@ label.inline input {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.language-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.language-field select {
|
||||
width: auto;
|
||||
min-width: 104px;
|
||||
}
|
||||
|
||||
.auth-view {
|
||||
min-height: calc(100vh - 120px);
|
||||
display: grid;
|
||||
@@ -355,6 +367,10 @@ dialog h2 {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.session {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar input {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
@@ -1,579 +0,0 @@
|
||||
const apiBase = document.body.dataset.apiPrefix || "/admin/api";
|
||||
const state = {
|
||||
token: sessionStorage.getItem("mcGatewayAdminToken") || "",
|
||||
user: null,
|
||||
routes: [],
|
||||
services: [],
|
||||
users: [],
|
||||
};
|
||||
|
||||
const el = (id) => document.getElementById(id);
|
||||
|
||||
function showAlert(message) {
|
||||
const box = el("alert");
|
||||
box.textContent = message;
|
||||
box.classList.toggle("hidden", !message);
|
||||
}
|
||||
|
||||
function setView(name) {
|
||||
for (const id of ["setupView", "loginView", "appView"]) {
|
||||
el(id).classList.toggle("hidden", id !== name);
|
||||
}
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const headers = { "Accept": "application/json" };
|
||||
if (options.body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
if (state.token) {
|
||||
headers.Authorization = `Bearer ${state.token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(apiBase + path, {
|
||||
method: options.method || "GET",
|
||||
headers,
|
||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
});
|
||||
|
||||
let data = {};
|
||||
const text = await res.text();
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
data = { error: text };
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || res.statusText);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
bindEvents();
|
||||
try {
|
||||
const setup = await api("/setup");
|
||||
if (setup.required) {
|
||||
setView("setupView");
|
||||
el("subtitle").textContent = "Setup";
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
showAlert(err.message);
|
||||
}
|
||||
|
||||
if (!state.token) {
|
||||
setView("loginView");
|
||||
el("subtitle").textContent = "Login";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
state.user = await api("/me");
|
||||
await showApp();
|
||||
} catch {
|
||||
sessionStorage.removeItem("mcGatewayAdminToken");
|
||||
state.token = "";
|
||||
setView("loginView");
|
||||
el("subtitle").textContent = "Login";
|
||||
}
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
el("setupForm").addEventListener("submit", submitSetup);
|
||||
el("loginForm").addEventListener("submit", submitLogin);
|
||||
el("logoutBtn").addEventListener("click", logout);
|
||||
el("routeSearch").addEventListener("input", debounce(loadRoutes, 180));
|
||||
el("newRouteBtn").addEventListener("click", () => openRouteDialog());
|
||||
el("newUserBtn").addEventListener("click", () => openUserDialog());
|
||||
el("routeForm").addEventListener("submit", saveRoute);
|
||||
el("userForm").addEventListener("submit", saveUser);
|
||||
|
||||
for (const button of document.querySelectorAll("[data-close]")) {
|
||||
button.addEventListener("click", () => button.closest("dialog").close());
|
||||
}
|
||||
for (const button of document.querySelectorAll(".tabs button")) {
|
||||
button.addEventListener("click", () => selectTab(button.dataset.tab));
|
||||
}
|
||||
}
|
||||
|
||||
async function submitSetup(event) {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
try {
|
||||
await api("/setup", {
|
||||
method: "POST",
|
||||
body: {
|
||||
username: form.get("username"),
|
||||
password: form.get("password"),
|
||||
},
|
||||
});
|
||||
showAlert("");
|
||||
setView("loginView");
|
||||
} catch (err) {
|
||||
showAlert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitLogin(event) {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
try {
|
||||
const data = await api("/auth/login", {
|
||||
method: "POST",
|
||||
body: {
|
||||
username: form.get("username"),
|
||||
password: form.get("password"),
|
||||
},
|
||||
});
|
||||
state.token = data.token;
|
||||
state.user = data.user;
|
||||
sessionStorage.setItem("mcGatewayAdminToken", state.token);
|
||||
showAlert("");
|
||||
await showApp();
|
||||
} catch (err) {
|
||||
showAlert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await api("/auth/logout", { method: "POST", body: {} });
|
||||
} catch {
|
||||
}
|
||||
sessionStorage.removeItem("mcGatewayAdminToken");
|
||||
state.token = "";
|
||||
state.user = null;
|
||||
setView("loginView");
|
||||
}
|
||||
|
||||
async function showApp() {
|
||||
setView("appView");
|
||||
el("subtitle").textContent = "Admin";
|
||||
el("sessionUser").textContent = `${state.user.username} (${state.user.role})`;
|
||||
el("logoutBtn").classList.remove("hidden");
|
||||
applyRoleVisibility();
|
||||
await loadRoutes();
|
||||
if (isMember()) {
|
||||
await loadStatus();
|
||||
await loadServices();
|
||||
await loadMetrics();
|
||||
}
|
||||
if (isAdmin()) {
|
||||
await loadUsers();
|
||||
await loadAudit();
|
||||
}
|
||||
}
|
||||
|
||||
function applyRoleVisibility() {
|
||||
const member = isMember();
|
||||
const admin = isAdmin();
|
||||
el("statusGrid").classList.toggle("hidden", !member);
|
||||
el("newRouteBtn").classList.toggle("hidden", !member);
|
||||
toggleTab("services", member);
|
||||
toggleTab("metrics", member);
|
||||
toggleTab("users", admin);
|
||||
toggleTab("audit", admin);
|
||||
selectTab("routes");
|
||||
}
|
||||
|
||||
function toggleTab(name, visible) {
|
||||
document.querySelector(`[data-tab="${name}"]`).classList.toggle("hidden", !visible);
|
||||
}
|
||||
|
||||
function selectTab(name) {
|
||||
for (const button of document.querySelectorAll(".tabs button")) {
|
||||
button.classList.toggle("active", button.dataset.tab === name);
|
||||
}
|
||||
for (const panel of document.querySelectorAll(".tab-panel")) {
|
||||
panel.classList.add("hidden");
|
||||
}
|
||||
el(`${name}Tab`).classList.remove("hidden");
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
try {
|
||||
const status = await api("/status");
|
||||
el("statusGrid").innerHTML = [
|
||||
stat("PID", status.pid),
|
||||
stat("Uptime", `${status.uptime_seconds}s`),
|
||||
stat("SQLite", status.db_path),
|
||||
stat("TCP/Admin", status.tcp_admin_port),
|
||||
].join("");
|
||||
} catch (err) {
|
||||
showAlert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRoutes() {
|
||||
try {
|
||||
const q = encodeURIComponent(el("routeSearch").value || "");
|
||||
const data = await api(q ? `/routes?q=${q}` : "/routes");
|
||||
state.routes = data.routes || [];
|
||||
renderRoutes();
|
||||
} catch (err) {
|
||||
showAlert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderRoutes() {
|
||||
const canWrite = isMember();
|
||||
el("routesBody").innerHTML = state.routes.map((route) => `
|
||||
<tr>
|
||||
<td>${escapeHTML(route.host)}</td>
|
||||
<td>${escapeHTML(route.upstream)}</td>
|
||||
<td>${badge(route.enabled ? "Enabled" : "Disabled", !route.enabled)}</td>
|
||||
<td>${escapeHTML(route.note || "")}</td>
|
||||
<td class="actions">${canWrite ? routeActions(route) : ""}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
|
||||
for (const button of document.querySelectorAll("[data-edit-route]")) {
|
||||
button.addEventListener("click", () => {
|
||||
const route = state.routes.find((item) => item.host === button.dataset.editRoute);
|
||||
openRouteDialog(route);
|
||||
});
|
||||
}
|
||||
for (const button of document.querySelectorAll("[data-delete-route]")) {
|
||||
button.addEventListener("click", () => removeRoute(button.dataset.deleteRoute));
|
||||
}
|
||||
}
|
||||
|
||||
function routeActions(route) {
|
||||
return `
|
||||
<div class="row-actions">
|
||||
<button class="secondary" type="button" data-edit-route="${escapeAttr(route.host)}">Edit</button>
|
||||
<button class="danger" type="button" data-delete-route="${escapeAttr(route.host)}">Delete</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function openRouteDialog(route = null) {
|
||||
const form = el("routeForm");
|
||||
form.reset();
|
||||
form.dataset.originalHost = route ? route.host : "";
|
||||
form.elements.host.disabled = Boolean(route);
|
||||
if (route) {
|
||||
form.elements.host.value = route.host;
|
||||
form.elements.upstream.value = route.upstream;
|
||||
form.elements.enabled.checked = route.enabled;
|
||||
form.elements.note.value = route.note || "";
|
||||
} else {
|
||||
form.elements.enabled.checked = true;
|
||||
}
|
||||
el("routeDialog").showModal();
|
||||
}
|
||||
|
||||
async function saveRoute(event) {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const host = form.dataset.originalHost || form.elements.host.value;
|
||||
try {
|
||||
await api(`/routes/${encodeURIComponent(host)}`, {
|
||||
method: "PUT",
|
||||
body: {
|
||||
upstream: form.elements.upstream.value,
|
||||
enabled: form.elements.enabled.checked,
|
||||
note: form.elements.note.value,
|
||||
},
|
||||
});
|
||||
el("routeDialog").close();
|
||||
await loadRoutes();
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRoute(host) {
|
||||
if (host === "default" && !confirm("Delete default route?")) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api(`/routes/${encodeURIComponent(host)}`, { method: "DELETE" });
|
||||
await loadRoutes();
|
||||
} catch (err) {
|
||||
showAlert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadServices() {
|
||||
try {
|
||||
const data = await api("/services");
|
||||
state.services = data.services || [];
|
||||
renderServices();
|
||||
} catch (err) {
|
||||
showAlert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderServices() {
|
||||
el("servicesGrid").innerHTML = state.services.map((service) => `
|
||||
<article class="service">
|
||||
<div>
|
||||
<span>${escapeHTML(service.name)}</span>
|
||||
<strong>${service.enabled ? "Enabled" : "Disabled"}${service.restart_required ? " / restart required" : ""}</strong>
|
||||
</div>
|
||||
${isAdmin() ? serviceForm(service) : serviceSummary(service)}
|
||||
</article>
|
||||
`).join("");
|
||||
|
||||
for (const form of document.querySelectorAll("[data-service-form]")) {
|
||||
form.addEventListener("submit", saveService);
|
||||
}
|
||||
for (const button of document.querySelectorAll("[data-restart-service]")) {
|
||||
button.addEventListener("click", () => restartService(button.dataset.restartService));
|
||||
}
|
||||
}
|
||||
|
||||
function serviceSummary(service) {
|
||||
return `<div><span>Port</span><strong>${service.port}</strong></div>`;
|
||||
}
|
||||
|
||||
function serviceForm(service) {
|
||||
const disabled = service.name === "tcp_admin" ? "disabled" : "";
|
||||
const optionFields = serviceOptionFields(service);
|
||||
return `
|
||||
<form data-service-form="${escapeAttr(service.name)}">
|
||||
<label class="inline">
|
||||
<input name="enabled" type="checkbox" ${service.enabled ? "checked" : ""} ${disabled}>
|
||||
Enabled
|
||||
</label>
|
||||
<label>
|
||||
Port
|
||||
<input name="port" type="number" min="1" max="65535" value="${service.port}">
|
||||
</label>
|
||||
${optionFields}
|
||||
<div class="row-actions">
|
||||
<button type="submit">Save</button>
|
||||
<button class="secondary" type="button" data-restart-service="${escapeAttr(service.name)}">Restart</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
function serviceOptionFields(service) {
|
||||
const options = service.options || {};
|
||||
if (service.name === "kcp") {
|
||||
return `
|
||||
<label>Data shards<input name="data_shards" type="number" min="1" value="${options.data_shards || 10}"></label>
|
||||
<label>Parity shards<input name="parity_shards" type="number" min="1" value="${options.parity_shards || 3}"></label>
|
||||
`;
|
||||
}
|
||||
if (service.name === "quic") {
|
||||
const protocols = Array.isArray(options.application_protocols) ? options.application_protocols.join(",") : "";
|
||||
return `<label>Protocols<input name="application_protocols" value="${escapeAttr(protocols)}"></label>`;
|
||||
}
|
||||
if (service.name === "websocket") {
|
||||
return `<label>Path<input name="path" value="${escapeAttr(options.path || "/")}"></label>`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function saveService(event) {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const name = form.dataset.serviceForm;
|
||||
const options = {};
|
||||
if (name === "kcp") {
|
||||
options.data_shards = Number(form.elements.data_shards.value);
|
||||
options.parity_shards = Number(form.elements.parity_shards.value);
|
||||
} else if (name === "quic") {
|
||||
options.application_protocols = form.elements.application_protocols.value.split(",").map((item) => item.trim()).filter(Boolean);
|
||||
} else if (name === "websocket") {
|
||||
options.path = form.elements.path.value;
|
||||
}
|
||||
|
||||
try {
|
||||
await api(`/services/${encodeURIComponent(name)}`, {
|
||||
method: "PUT",
|
||||
body: {
|
||||
enabled: name === "tcp_admin" ? true : form.elements.enabled.checked,
|
||||
port: Number(form.elements.port.value),
|
||||
options,
|
||||
},
|
||||
});
|
||||
await loadServices();
|
||||
} catch (err) {
|
||||
showAlert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function restartService(name) {
|
||||
try {
|
||||
await api(`/services/${encodeURIComponent(name)}/restart`, { method: "POST", body: {} });
|
||||
await loadServices();
|
||||
} catch (err) {
|
||||
showAlert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const data = await api("/users");
|
||||
state.users = data.users || [];
|
||||
renderUsers();
|
||||
} catch (err) {
|
||||
showAlert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderUsers() {
|
||||
el("usersBody").innerHTML = state.users.map((user) => `
|
||||
<tr>
|
||||
<td>${escapeHTML(user.username)}</td>
|
||||
<td>${escapeHTML(user.role)}</td>
|
||||
<td>${badge(user.disabled ? "Disabled" : "Active", user.disabled)}</td>
|
||||
<td class="actions">
|
||||
<div class="row-actions">
|
||||
<button class="secondary" type="button" data-edit-user="${escapeAttr(user.username)}">Edit</button>
|
||||
<button class="danger" type="button" data-delete-user="${escapeAttr(user.username)}">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
|
||||
for (const button of document.querySelectorAll("[data-edit-user]")) {
|
||||
button.addEventListener("click", () => {
|
||||
const user = state.users.find((item) => item.username === button.dataset.editUser);
|
||||
openUserDialog(user);
|
||||
});
|
||||
}
|
||||
for (const button of document.querySelectorAll("[data-delete-user]")) {
|
||||
button.addEventListener("click", () => removeUser(button.dataset.deleteUser));
|
||||
}
|
||||
}
|
||||
|
||||
function openUserDialog(user = null) {
|
||||
const form = el("userForm");
|
||||
form.reset();
|
||||
form.dataset.originalUsername = user ? user.username : "";
|
||||
form.elements.username.disabled = Boolean(user);
|
||||
form.elements.password.required = !user;
|
||||
if (user) {
|
||||
form.elements.username.value = user.username;
|
||||
form.elements.role.value = user.role;
|
||||
form.elements.disabled.checked = user.disabled;
|
||||
} else {
|
||||
form.elements.role.value = "member";
|
||||
}
|
||||
el("userDialog").showModal();
|
||||
}
|
||||
|
||||
async function saveUser(event) {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const username = form.dataset.originalUsername || form.elements.username.value;
|
||||
const body = {
|
||||
role: form.elements.role.value,
|
||||
disabled: form.elements.disabled.checked,
|
||||
};
|
||||
if (form.elements.password.value) {
|
||||
body.password = form.elements.password.value;
|
||||
}
|
||||
|
||||
try {
|
||||
if (form.dataset.originalUsername) {
|
||||
await api(`/users/${encodeURIComponent(username)}`, { method: "PATCH", body });
|
||||
} else {
|
||||
body.username = username;
|
||||
await api("/users", { method: "POST", body });
|
||||
}
|
||||
el("userDialog").close();
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
showAlert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeUser(username) {
|
||||
if (!confirm(`Delete user ${username}?`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api(`/users/${encodeURIComponent(username)}`, { method: "DELETE" });
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
showAlert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMetrics() {
|
||||
try {
|
||||
const data = await api("/metrics");
|
||||
el("metricsGrid").innerHTML = [
|
||||
stat("Total", data.total_connections),
|
||||
stat("Active", data.active_connections),
|
||||
stat("TCP", data.tcp_connections),
|
||||
stat("WebSocket", data.websocket_connections),
|
||||
stat("Misses", data.route_misses),
|
||||
stat("Dial errors", data.upstream_dial_errors),
|
||||
].join("");
|
||||
const hits = data.route_hits || {};
|
||||
el("routeHits").innerHTML = Object.keys(hits).length
|
||||
? Object.entries(hits).map(([host, count]) => `<span class="chip">${escapeHTML(host)}: ${count}</span>`).join("")
|
||||
: `<span class="chip">No hits</span>`;
|
||||
} catch (err) {
|
||||
showAlert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAudit() {
|
||||
try {
|
||||
const data = await api("/audit-logs");
|
||||
el("auditBody").innerHTML = (data.audit_logs || []).map((item) => `
|
||||
<tr>
|
||||
<td>${new Date(item.created_at * 1000).toLocaleString()}</td>
|
||||
<td>${escapeHTML(item.actor)}</td>
|
||||
<td>${escapeHTML(item.action)}</td>
|
||||
<td>${escapeHTML(item.target_type)}:${escapeHTML(item.target_id)}</td>
|
||||
<td>${badge(item.success ? "Success" : "Failed", !item.success)}</td>
|
||||
<td>${escapeHTML(item.message || "")}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
} catch (err) {
|
||||
showAlert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function stat(label, value) {
|
||||
return `<div class="stat"><span>${escapeHTML(label)}</span><strong>${escapeHTML(String(value ?? ""))}</strong></div>`;
|
||||
}
|
||||
|
||||
function badge(text, off = false) {
|
||||
return `<span class="badge ${off ? "off" : ""}">${escapeHTML(text)}</span>`;
|
||||
}
|
||||
|
||||
function isAdmin() {
|
||||
return state.user && state.user.role === "admin";
|
||||
}
|
||||
|
||||
function isMember() {
|
||||
return state.user && (state.user.role === "admin" || state.user.role === "member");
|
||||
}
|
||||
|
||||
function debounce(fn, wait) {
|
||||
let id = 0;
|
||||
return (...args) => {
|
||||
clearTimeout(id);
|
||||
id = setTimeout(() => fn(...args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
function escapeHTML(value) {
|
||||
return String(value).replace(/[&<>"']/g, (ch) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
"\"": """,
|
||||
"'": "'",
|
||||
}[ch]));
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return escapeHTML(value).replace(/`/g, "`");
|
||||
}
|
||||
|
||||
boot();
|
||||
@@ -6,7 +6,7 @@
|
||||
<title>mc-gateway admin</title>
|
||||
<link rel="stylesheet" href="app.css">
|
||||
</head>
|
||||
<body data-api-prefix="__ADMIN_API_PREFIX__">
|
||||
<body>
|
||||
<main class="shell">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
@@ -14,8 +14,15 @@
|
||||
<p id="subtitle">Admin</p>
|
||||
</div>
|
||||
<div class="session">
|
||||
<label class="language-field">
|
||||
<span data-i18n="language">Language</span>
|
||||
<select id="languageSelect" aria-label="Language">
|
||||
<option value="en" data-i18n="languageEnglish">English</option>
|
||||
<option value="zh" data-i18n="languageChinese">中文</option>
|
||||
</select>
|
||||
</label>
|
||||
<span id="sessionUser"></span>
|
||||
<button id="logoutBtn" class="ghost hidden" type="button">Logout</button>
|
||||
<button id="logoutBtn" class="ghost hidden" type="button" data-i18n="logout">Logout</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -23,31 +30,31 @@
|
||||
|
||||
<section id="setupView" class="auth-view hidden">
|
||||
<form id="setupForm" class="panel compact">
|
||||
<h2>Initial admin</h2>
|
||||
<h2 data-i18n="initialAdmin">Initial admin</h2>
|
||||
<label>
|
||||
Username
|
||||
<span data-i18n="username">Username</span>
|
||||
<input name="username" autocomplete="username" value="admin">
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<span data-i18n="password">Password</span>
|
||||
<input name="password" autocomplete="new-password" type="password" required>
|
||||
</label>
|
||||
<button type="submit">Create admin</button>
|
||||
<button type="submit" data-i18n="createAdmin">Create admin</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="loginView" class="auth-view hidden">
|
||||
<form id="loginForm" class="panel compact">
|
||||
<h2>Login</h2>
|
||||
<h2 data-i18n="login">Login</h2>
|
||||
<label>
|
||||
Username
|
||||
<span data-i18n="username">Username</span>
|
||||
<input name="username" autocomplete="username" required>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<span data-i18n="password">Password</span>
|
||||
<input name="password" autocomplete="current-password" type="password" required>
|
||||
</label>
|
||||
<button type="submit">Login</button>
|
||||
<button type="submit" data-i18n="login">Login</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -55,27 +62,27 @@
|
||||
<section id="statusGrid" class="status-grid"></section>
|
||||
|
||||
<nav class="tabs">
|
||||
<button data-tab="routes" class="active" type="button">Routes</button>
|
||||
<button data-tab="services" type="button">Services</button>
|
||||
<button data-tab="users" type="button">Users</button>
|
||||
<button data-tab="metrics" type="button">Metrics</button>
|
||||
<button data-tab="audit" type="button">Audit</button>
|
||||
<button data-tab="routes" class="active" type="button" data-i18n="routes">Routes</button>
|
||||
<button data-tab="services" type="button" data-i18n="services">Services</button>
|
||||
<button data-tab="users" type="button" data-i18n="users">Users</button>
|
||||
<button data-tab="metrics" type="button" data-i18n="metrics">Metrics</button>
|
||||
<button data-tab="audit" type="button" data-i18n="audit">Audit</button>
|
||||
</nav>
|
||||
|
||||
<section id="routesTab" class="tab-panel">
|
||||
<div class="toolbar">
|
||||
<input id="routeSearch" placeholder="Search host, upstream, note">
|
||||
<button id="newRouteBtn" type="button">New route</button>
|
||||
<input id="routeSearch" placeholder="Search host, upstream, note" data-i18n-placeholder="routeSearch">
|
||||
<button id="newRouteBtn" type="button" data-i18n="newRoute">New route</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Host</th>
|
||||
<th>Upstream</th>
|
||||
<th>Enabled</th>
|
||||
<th>Note</th>
|
||||
<th class="actions">Actions</th>
|
||||
<th data-i18n="host">Host</th>
|
||||
<th data-i18n="upstream">Upstream</th>
|
||||
<th data-i18n="enabled">Enabled</th>
|
||||
<th data-i18n="note">Note</th>
|
||||
<th class="actions" data-i18n="actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="routesBody"></tbody>
|
||||
@@ -89,16 +96,16 @@
|
||||
|
||||
<section id="usersTab" class="tab-panel hidden">
|
||||
<div class="toolbar">
|
||||
<button id="newUserBtn" type="button">New user</button>
|
||||
<button id="newUserBtn" type="button" data-i18n="newUser">New user</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Role</th>
|
||||
<th>Disabled</th>
|
||||
<th class="actions">Actions</th>
|
||||
<th data-i18n="username">Username</th>
|
||||
<th data-i18n="role">Role</th>
|
||||
<th data-i18n="disabled">Disabled</th>
|
||||
<th class="actions" data-i18n="actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usersBody"></tbody>
|
||||
@@ -109,7 +116,7 @@
|
||||
<section id="metricsTab" class="tab-panel hidden">
|
||||
<div id="metricsGrid" class="status-grid"></div>
|
||||
<div class="panel">
|
||||
<h2>Route hits</h2>
|
||||
<h2 data-i18n="routeHits">Route hits</h2>
|
||||
<div id="routeHits" class="chips"></div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -119,12 +126,12 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Actor</th>
|
||||
<th>Action</th>
|
||||
<th>Target</th>
|
||||
<th>Result</th>
|
||||
<th>Message</th>
|
||||
<th data-i18n="time">Time</th>
|
||||
<th data-i18n="actor">Actor</th>
|
||||
<th data-i18n="action">Action</th>
|
||||
<th data-i18n="target">Target</th>
|
||||
<th data-i18n="result">Result</th>
|
||||
<th data-i18n="message">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="auditBody"></tbody>
|
||||
@@ -136,60 +143,61 @@
|
||||
|
||||
<dialog id="routeDialog">
|
||||
<form id="routeForm" method="dialog">
|
||||
<h2>Route</h2>
|
||||
<h2 data-i18n="route">Route</h2>
|
||||
<label>
|
||||
Host
|
||||
<span data-i18n="host">Host</span>
|
||||
<input name="host" required>
|
||||
</label>
|
||||
<label>
|
||||
Upstream
|
||||
<span data-i18n="upstream">Upstream</span>
|
||||
<input name="upstream" required placeholder="127.0.0.1:25565">
|
||||
</label>
|
||||
<label class="inline">
|
||||
<input name="enabled" type="checkbox" checked>
|
||||
Enabled
|
||||
<span data-i18n="enabled">Enabled</span>
|
||||
</label>
|
||||
<label>
|
||||
Note
|
||||
<span data-i18n="note">Note</span>
|
||||
<input name="note">
|
||||
</label>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" data-close>Cancel</button>
|
||||
<button type="submit">Save</button>
|
||||
<button type="button" data-close data-i18n="cancel">Cancel</button>
|
||||
<button type="submit" data-i18n="save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="userDialog">
|
||||
<form id="userForm" method="dialog">
|
||||
<h2>User</h2>
|
||||
<h2 data-i18n="user">User</h2>
|
||||
<label>
|
||||
Username
|
||||
<span data-i18n="username">Username</span>
|
||||
<input name="username" required>
|
||||
</label>
|
||||
<label>
|
||||
Role
|
||||
<span data-i18n="role">Role</span>
|
||||
<select name="role">
|
||||
<option value="admin">Admin</option>
|
||||
<option value="member">Member</option>
|
||||
<option value="guest">Guest</option>
|
||||
<option value="admin" data-i18n="roleAdmin">Admin</option>
|
||||
<option value="member" data-i18n="roleMember">Member</option>
|
||||
<option value="guest" data-i18n="roleGuest">Guest</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<span data-i18n="password">Password</span>
|
||||
<input name="password" type="password">
|
||||
</label>
|
||||
<label class="inline">
|
||||
<input name="disabled" type="checkbox">
|
||||
Disabled
|
||||
<span data-i18n="disabled">Disabled</span>
|
||||
</label>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" data-close>Cancel</button>
|
||||
<button type="submit">Save</button>
|
||||
<button type="button" data-close data-i18n="cancel">Cancel</button>
|
||||
<button type="submit" data-i18n="save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="app.js"></script>
|
||||
<script src="config.js"></script>
|
||||
<script type="module" src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -101,8 +101,12 @@ func TestParseStartupConfigReturnsErrors(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "api prefix under asset path",
|
||||
env: map[string]string{adminEnvAPIPrefix: "/admin/app.js/api"},
|
||||
name: "api prefix under js asset path",
|
||||
env: map[string]string{adminEnvAPIPrefix: "/admin/js/api"},
|
||||
},
|
||||
{
|
||||
name: "api prefix under config asset path",
|
||||
env: map[string]string{adminEnvAPIPrefix: "/admin/config.js/api"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
@@ -11,6 +13,10 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
if handled, code := runPluginCLI(os.Args[1:]); handled {
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
if err := loadConfig(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -127,17 +133,39 @@ func mapToHost(conn net.Conn) net.Conn {
|
||||
|
||||
var client net.Conn
|
||||
|
||||
ok, err = invokeFirstHookHandler(api.HookUpstream, Handler2[net.Conn, string, bool](conn, host), func(handler func(net.Conn, string) (net.Conn, error)) error {
|
||||
var err error
|
||||
client, err = handler(conn, host)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to invoke upstream hook")
|
||||
return nil
|
||||
if pluginsManager != nil {
|
||||
result, err := pluginsManager.ConnectUpstream(context.Background(), api.UpstreamConnectRequest{
|
||||
Source: conn,
|
||||
Host: mcHost,
|
||||
Upstream: host,
|
||||
InitialData: append([]byte(nil), buf[:n]...),
|
||||
})
|
||||
if err != nil {
|
||||
log.Err(err).
|
||||
Str("client", conn.RemoteAddr().String()).
|
||||
Str("host", mcHost).
|
||||
Str("mc", host).
|
||||
Msg("failed to invoke managed upstream plugin")
|
||||
return nil
|
||||
}
|
||||
if result.Handled {
|
||||
client = result.Conn
|
||||
}
|
||||
}
|
||||
|
||||
if !ok {
|
||||
if client == nil {
|
||||
ok, err = invokeFirstHookHandler(api.HookUpstream, Handler2[net.Conn, string, bool](conn, host), func(handler func(net.Conn, string) (net.Conn, error)) error {
|
||||
var err error
|
||||
client, err = handler(conn, host)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to invoke upstream hook")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if client == nil {
|
||||
target := upstreamtarget.Parse(host)
|
||||
switch target.Protocol {
|
||||
case upstreamtarget.ProtocolQUIC:
|
||||
|
||||
@@ -1,12 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/tursom/mc-gateway/internal/admindb"
|
||||
"github.com/tursom/mc-gateway/internal/pluginmanager"
|
||||
"github.com/tursom/mc-gateway/plugin/api"
|
||||
)
|
||||
|
||||
func TestMapToHostUsesManagedPluginBeforeLegacyHook(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
packet := gatewayTestPacket("play.example")
|
||||
source := newGatewayTestConn(packet)
|
||||
managedUpstream := newGatewayTestConn(nil)
|
||||
legacyUpstream := newGatewayTestConn(nil)
|
||||
setGatewayTestRoutes(map[string]string{
|
||||
"play.example": "backend.example:25565",
|
||||
})
|
||||
pluginsManager = pluginmanager.New(pluginmanager.Options{
|
||||
DB: newGatewayTestPluginDB(t),
|
||||
ArtifactRoot: t.TempDir(),
|
||||
Adapter: gatewayTestPluginAdapter{handler: func(req api.UpstreamConnectRequest) (net.Conn, error) {
|
||||
if req.Host != "play.example" || req.Upstream != "backend.example:25565" || !bytes.Equal(req.InitialData, packet) {
|
||||
t.Fatalf("managed request = %+v, initial=%v", req, req.InitialData)
|
||||
}
|
||||
return managedUpstream, nil
|
||||
}},
|
||||
})
|
||||
artifact := uploadGatewayTestArtifact(t, pluginsManager, "managed-upstream")
|
||||
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "managed-upstream", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
if _, err := pluginsManager.Enable(context.Background(), "admin", "managed-upstream"); err != nil {
|
||||
t.Fatalf("Enable() error = %v", err)
|
||||
}
|
||||
registerGatewayUpstreamHook(
|
||||
t,
|
||||
func(net.Conn, string) bool { return true },
|
||||
func(net.Conn, string) (net.Conn, error) { return legacyUpstream, nil },
|
||||
)
|
||||
|
||||
got := mapToHost(source)
|
||||
if got != managedUpstream {
|
||||
t.Fatalf("mapToHost() = %v, want managed upstream", got)
|
||||
}
|
||||
if !bytes.Equal(managedUpstream.writeBuf.Bytes(), packet) {
|
||||
t.Fatalf("managed upstream initial packet = %v, want %v", managedUpstream.writeBuf.Bytes(), packet)
|
||||
}
|
||||
if legacyUpstream.writeBuf.Len() != 0 {
|
||||
t.Fatalf("legacy upstream was used: %v", legacyUpstream.writeBuf.Bytes())
|
||||
}
|
||||
|
||||
if _, err := pluginsManager.Disable(context.Background(), "admin", "managed-upstream"); err != nil {
|
||||
t.Fatalf("Disable() error = %v", err)
|
||||
}
|
||||
nextSource := newGatewayTestConn(packet)
|
||||
if got := mapToHost(nextSource); got != legacyUpstream {
|
||||
t.Fatalf("mapToHost() after disable = %v, want legacy upstream", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapToHostRoutesThroughHookAndForwardsInitialPacket(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
@@ -161,3 +225,110 @@ func TestMapToHostClosesUpstreamWhenInitialWriteFails(t *testing.T) {
|
||||
t.Fatal("upstream was not closed after write failure")
|
||||
}
|
||||
}
|
||||
|
||||
type gatewayTestPluginAdapter struct {
|
||||
handler api.UpstreamConnectHandler
|
||||
}
|
||||
|
||||
func (a gatewayTestPluginAdapter) Load(_ context.Context, _ pluginmanager.ArtifactRecord, _ pluginmanager.PluginRecord, gateway *pluginmanager.Gateway) (api.Plugin, error) {
|
||||
handler := a.handler
|
||||
if handler == nil {
|
||||
handler = func(api.UpstreamConnectRequest) (net.Conn, error) {
|
||||
return nil, api.ErrPass
|
||||
}
|
||||
}
|
||||
if err := api.RegisterHookHandler(
|
||||
gateway,
|
||||
api.HookUpstreamConnect,
|
||||
func(api.UpstreamConnectRequest) bool { return true },
|
||||
handler,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &gatewayPluginStub{}, nil
|
||||
}
|
||||
|
||||
func newGatewayTestPluginDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := admindb.Open(filepath.Join(t.TempDir(), "gateway.sqlite3"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := admindb.Migrate(db); err != nil {
|
||||
t.Fatalf("Migrate() error = %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func uploadGatewayTestArtifact(t *testing.T, manager *pluginmanager.Manager, pluginID string) pluginmanager.ArtifactRecord {
|
||||
t.Helper()
|
||||
artifact, err := manager.UploadArtifact(context.Background(), pluginmanager.ArtifactUpload{
|
||||
SourcePath: writeGatewayTestMCGP(t, pluginID),
|
||||
FileName: pluginID + ".mcgp",
|
||||
Actor: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UploadArtifact() error = %v", err)
|
||||
}
|
||||
return artifact
|
||||
}
|
||||
|
||||
func writeGatewayTestMCGP(t *testing.T, pluginID string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "plugin.mcgp")
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Create zip error = %v", err)
|
||||
}
|
||||
writer := zip.NewWriter(file)
|
||||
entries := map[string][]byte{
|
||||
"manifest.json": gatewayTestManifest(t, pluginID),
|
||||
"plugin.so": []byte("fake plugin bytes " + pluginID),
|
||||
}
|
||||
for name, data := range entries {
|
||||
entry, err := writer.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("Create entry error = %v", err)
|
||||
}
|
||||
if _, err := entry.Write(data); err != nil {
|
||||
t.Fatalf("Write entry error = %v", err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("Close zip writer error = %v", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatalf("Close zip file error = %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func gatewayTestManifest(t *testing.T, pluginID string) []byte {
|
||||
t.Helper()
|
||||
manifest := pluginmanager.Manifest{
|
||||
SchemaVersion: pluginmanager.SchemaVersion,
|
||||
ID: pluginID,
|
||||
Name: "Managed Upstream",
|
||||
Version: "0.1.0",
|
||||
ArtifactType: pluginmanager.ArtifactTypeBinary,
|
||||
Runtime: pluginmanager.RuntimeManifest{
|
||||
Type: pluginmanager.RuntimeGoPlugin,
|
||||
Entry: pluginmanager.RuntimeEntry,
|
||||
EntrySymbol: "Plugin",
|
||||
},
|
||||
APIVersion: pluginmanager.APIVersion,
|
||||
GoVersion: runtime.Version(),
|
||||
GOOS: runtime.GOOS,
|
||||
GOARCH: runtime.GOARCH,
|
||||
ExtensionPoints: []pluginmanager.ExtensionPoint{{
|
||||
Type: "hook",
|
||||
Key: pluginmanager.ExtensionUpstreamConnect,
|
||||
}},
|
||||
}
|
||||
data, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal manifest error = %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
94
cmd/gateway/plugin_cli.go
Normal file
94
cmd/gateway/plugin_cli.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/tursom/mc-gateway/internal/pluginmanager"
|
||||
)
|
||||
|
||||
func runPluginCLI(args []string) (bool, int) {
|
||||
if len(args) < 2 || args[0] != "plugin" {
|
||||
return false, 0
|
||||
}
|
||||
if len(args) < 3 {
|
||||
fmt.Fprintln(os.Stderr, "usage: gateway plugin inspect|validate|compat <artifact.mcgp>")
|
||||
return true, 2
|
||||
}
|
||||
|
||||
command, packagePath := args[1], args[2]
|
||||
switch command {
|
||||
case "inspect":
|
||||
manifest, err := readPackageManifest(packagePath)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(manifest); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "validate", "compat":
|
||||
tmpRoot, err := os.MkdirTemp("", "mcgp-cli-*")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
defer os.RemoveAll(tmpRoot)
|
||||
store := pluginmanager.NewArtifactStore(tmpRoot)
|
||||
artifact, err := store.ValidateAndStore(pluginmanager.ArtifactUpload{
|
||||
SourcePath: packagePath,
|
||||
FileName: filepath.Base(packagePath),
|
||||
Actor: "cli",
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "ok plugin=%s version=%s sha256=%s api=%s go=%s %s/%s\n",
|
||||
artifact.PluginID, artifact.Version, artifact.SHA256, artifact.APIVersion, artifact.GoVersion, artifact.GOOS, artifact.GOARCH)
|
||||
return true, 0
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown plugin command %q\n", command)
|
||||
return true, 2
|
||||
}
|
||||
}
|
||||
|
||||
func readPackageManifest(packagePath string) (pluginmanager.Manifest, error) {
|
||||
reader, err := zip.OpenReader(packagePath)
|
||||
if err != nil {
|
||||
return pluginmanager.Manifest{}, err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
for _, file := range reader.File {
|
||||
if file.Name != "manifest.json" {
|
||||
continue
|
||||
}
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
return pluginmanager.Manifest{}, err
|
||||
}
|
||||
defer rc.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(rc, pluginmanager.DefaultManifestMaxBytes+1))
|
||||
if err != nil {
|
||||
return pluginmanager.Manifest{}, err
|
||||
}
|
||||
if len(data) > pluginmanager.DefaultManifestMaxBytes {
|
||||
return pluginmanager.Manifest{}, fmt.Errorf("manifest.json exceeds %d bytes", pluginmanager.DefaultManifestMaxBytes)
|
||||
}
|
||||
var manifest pluginmanager.Manifest
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
return pluginmanager.Manifest{}, err
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
return pluginmanager.Manifest{}, fmt.Errorf("manifest.json is required")
|
||||
}
|
||||
@@ -28,6 +28,7 @@ func saveGatewayState(t *testing.T) func() {
|
||||
oldAdminStartup := adminStartup
|
||||
oldAdminDB := adminDB
|
||||
oldAdminDBPath := adminDBPath
|
||||
oldPluginsManager := pluginsManager
|
||||
oldAdminSessionManager := adminSessionManager
|
||||
oldRouteSnapshot := routeSnapshot.Clone()
|
||||
oldGatewayMetrics := gatewayMetrics
|
||||
@@ -51,6 +52,7 @@ func saveGatewayState(t *testing.T) func() {
|
||||
}
|
||||
adminDB = nil
|
||||
adminDBPath = ""
|
||||
pluginsManager = nil
|
||||
adminSessionManager = adminsession.NewManager()
|
||||
publishRouteSnapshot(nil)
|
||||
gatewayMetrics = gatewaymetrics.New()
|
||||
@@ -70,6 +72,7 @@ func saveGatewayState(t *testing.T) func() {
|
||||
adminStartup = oldAdminStartup
|
||||
adminDB = oldAdminDB
|
||||
adminDBPath = oldAdminDBPath
|
||||
pluginsManager = oldPluginsManager
|
||||
adminSessionManager = oldAdminSessionManager
|
||||
publishRouteSnapshot(oldRouteSnapshot)
|
||||
gatewayMetrics = oldGatewayMetrics
|
||||
|
||||
5
compose.override.yaml
Normal file
5
compose.override.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
services:
|
||||
mc-gateway:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
22
compose.yaml
Normal file
22
compose.yaml
Normal file
@@ -0,0 +1,22 @@
|
||||
services:
|
||||
mc-gateway:
|
||||
image: ${MC_GATEWAY_IMAGE:-ghcr.io/tursom/mc-gateway:latest}
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MC_GATEWAY_TCP_ADMIN_PORT: "${MC_GATEWAY_TCP_ADMIN_PORT:-25565}"
|
||||
MC_GATEWAY_ADMIN_PATH: "${MC_GATEWAY_ADMIN_PATH:-/admin/}"
|
||||
MC_GATEWAY_ADMIN_API_PREFIX: "${MC_GATEWAY_ADMIN_API_PREFIX:-/admin/api}"
|
||||
MC_GATEWAY_ADMIN_STATIC_DIR: "${MC_GATEWAY_ADMIN_STATIC_DIR:-/usr/share/mc-gateway/admin_static}"
|
||||
MC_GATEWAY_DB: "${MC_GATEWAY_DB:-/data/mc-gateway.sqlite3}"
|
||||
MC_GATEWAY_ADMIN_PASSWORD: "${MC_GATEWAY_ADMIN_PASSWORD:-}"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- wget -Y off -qO- "http://127.0.0.1:$${MC_GATEWAY_TCP_ADMIN_PORT:-25565}$${MC_GATEWAY_ADMIN_API_PREFIX:-/admin/api}/setup" >/dev/null
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
@@ -56,6 +56,7 @@
|
||||
| TCP/Admin 监听端口 | `25565` | `MC_GATEWAY_TCP_ADMIN_PORT` |
|
||||
| Admin 页面路径 | `/admin/` | `MC_GATEWAY_ADMIN_PATH` |
|
||||
| Admin API 前缀 | `/admin/api` | `MC_GATEWAY_ADMIN_API_PREFIX` |
|
||||
| Admin 静态目录 | `cmd/gateway/admin_static` | `MC_GATEWAY_ADMIN_STATIC_DIR` |
|
||||
| KCP | 默认禁用 | 后台配置 |
|
||||
| QUIC | 默认禁用 | 后台配置 |
|
||||
| WebSocket | 默认禁用 | 后台配置 |
|
||||
@@ -67,7 +68,8 @@
|
||||
- `MC_GATEWAY_TCP_ADMIN_PORT` 只在启动时读取,必须是 `1-65535` 的整数;为空时使用 `25565`。
|
||||
- `MC_GATEWAY_ADMIN_PATH` 只在启动时读取,必须以 `/` 开头,规范化为以 `/` 结尾;为空时使用 `/admin/`。
|
||||
- `MC_GATEWAY_ADMIN_API_PREFIX` 只在启动时读取,必须以 `/` 开头,规范化为不以 `/` 结尾;为空时使用 `/admin/api`。
|
||||
- `MC_GATEWAY_ADMIN_API_PREFIX` 不能等于 `MC_GATEWAY_ADMIN_PATH`,也不能落在静态资源路径下。
|
||||
- `MC_GATEWAY_ADMIN_API_PREFIX` 不能等于 `MC_GATEWAY_ADMIN_PATH`,也不能落在静态资源路径下,例如 `config.js` 或 `js/`。
|
||||
- `MC_GATEWAY_ADMIN_STATIC_DIR` 指向 Admin 前端构建产物目录;Docker 镜像内使用 `/usr/share/mc-gateway/admin_static`。
|
||||
- 环境变量覆盖的是本次进程的 Admin 入口;第一次创建 SQLite 默认服务配置时,应把解析后的 TCP/Admin 端口写入 `services.tcp_admin.port`。
|
||||
|
||||
启动流程:
|
||||
@@ -130,7 +132,7 @@ Accept
|
||||
|
||||
## HTTP 路由与页面
|
||||
|
||||
页面使用 Go `embed` 打包到单个二进制,不引入 Node 构建链。
|
||||
Admin 前端源码使用 TypeScript 拆分,构建为原生 ES modules。Go 不再 `embed` 前端文件,而是从 `MC_GATEWAY_ADMIN_STATIC_DIR` 指向的目录读取并透传静态响应。运行时 API 前缀通过动态 `config.js` 响应注入。
|
||||
|
||||
建议目录:
|
||||
|
||||
@@ -140,10 +142,11 @@ cmd/gateway/admin_api.go
|
||||
cmd/gateway/admin_auth.go
|
||||
cmd/gateway/admin_db.go
|
||||
cmd/gateway/admin_static.go
|
||||
cmd/gateway/admin_frontend/src/
|
||||
cmd/gateway/admin_static/
|
||||
index.html
|
||||
app.css
|
||||
app.js
|
||||
js/
|
||||
```
|
||||
|
||||
API 路由:
|
||||
@@ -152,7 +155,8 @@ API 路由:
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/admin/` | 公开页面 | 管理页面入口或首次初始化页面 |
|
||||
| GET | `/admin/app.css` | 公开页面 | 页面样式 |
|
||||
| GET | `/admin/app.js` | 公开页面 | 页面脚本 |
|
||||
| GET | `/admin/config.js` | 公开页面 | 运行时前端配置 |
|
||||
| GET | `/admin/js/main.js` | 公开页面 | 页面脚本入口 |
|
||||
| POST | `/admin/api/setup` | 仅用户表为空 | 创建第一个管理员 |
|
||||
| POST | `/admin/api/auth/login` | 未登录 | 用户名密码登录 |
|
||||
| POST | `/admin/api/auth/logout` | 已登录 | 注销当前会话 |
|
||||
|
||||
85
docs/plugin-implementation-plan.md
Normal file
85
docs/plugin-implementation-plan.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# 插件系统阶段实现计划
|
||||
|
||||
本文以 [plugin-system-design.md](plugin-system-design.md) 作为最终目标设计文档,把插件系统拆分成多个可上线的实现阶段。每个阶段都必须在结束时保持 gateway 当前可用:可以启动、可以回滚、可以排障,且不会要求后续阶段补齐后才能恢复基本能力。
|
||||
|
||||
## 拆分原则
|
||||
|
||||
- 以可用的纵向切片拆分,而不是按数据库、API、UI、SDK 等横向模块拆分。
|
||||
- 每个阶段都必须有明确的启用路径、失败回退路径和最小运维证据。
|
||||
- 默认运行路径保持保守:先稳定 `go-plugin + upstream.connect/v1`,再增加源码包、治理、观测和未来 runtime。
|
||||
- 未来能力必须可关闭、可灰度或只做设计预留,不能破坏前一阶段的可用状态。
|
||||
- `plugin-system-design.md` 是目标状态;阶段文档只描述实现顺序和阶段边界。
|
||||
|
||||
## 阶段总览
|
||||
|
||||
| 阶段 | 文档 | 阶段结束时可用状态 |
|
||||
| --- | --- | --- |
|
||||
| 1 | [Managed Binary Plugin MVP](plugin-implementation-stages/phase-01-managed-binary-mvp.md) | 管理员可以上传二进制 `.mcgp`,通过 Admin API/CLI 加载、启用、禁用、删除可信插件;`upstream.connect/v1` dialer mode 可用 |
|
||||
| 2 | [Protocol Proxy MVP](plugin-implementation-stages/phase-02-protocol-proxy-mvp.md) | `upstream.connect/v1` protocol-proxy mode 可用,插件可以接管 MC 字节流并实现登录代理示例 |
|
||||
| 3 | [Source Package Builder](plugin-implementation-stages/phase-03-source-package-builder.md) | 管理员可以上传 source `.mcgp`,受控 builder 产出可加载 artifact,构建失败不影响当前插件 |
|
||||
| 4 | [Admin UI, Config, Secret, Rollback](plugin-implementation-stages/phase-04-admin-ui-config-secret-rollback.md) | 管理页具备可操作的插件管理闭环,支持配置 schema、secret、配置快照和回滚 |
|
||||
| 5 | [Governance And Release Gates](plugin-implementation-stages/phase-05-governance-release-gates.md) | 生产启用前有准入策略、review、冲突分析、preflight/self-test、性能门禁和安全公告处理 |
|
||||
| 6 | [Observability And Operations](plugin-implementation-stages/phase-06-observability-operations.md) | 插件 metrics、events、trace、日志、诊断、background task、plugin_data 和文件资源治理可用 |
|
||||
| 7 | [Extension Ecosystem](plugin-implementation-stages/phase-07-extension-ecosystem.md) | 在稳定主路径上增加 route/status/provider/event/rule/Admin auth 等扩展点和官方插件能力 |
|
||||
| 8 | [Future Runtimes And Distribution](plugin-implementation-stages/phase-08-future-runtimes-distribution.md) | 可选引入 `go-plugin-process`、sandbox/WASM、ingress service、仓库、签名和构建期增强;默认路径仍可运行 |
|
||||
|
||||
## 功能到阶段映射
|
||||
|
||||
下表用于确认 [plugin-system-design.md](plugin-system-design.md) 中的目标能力已经拆入某个阶段。一个能力可能在早期阶段先实现最小可用版本,后续阶段再补齐治理、UI 或生态扩展。
|
||||
|
||||
| 目标能力 | 阶段 | 拆分说明 |
|
||||
| --- | --- | --- |
|
||||
| `.mcgp` binary artifact、manifest 静态校验、artifact 登记 | 1 | 先支持二进制可信 Go plugin,上传不执行代码 |
|
||||
| Plugin Manager、desired/runtime state、dispatch table、审计 | 1 | 建立正式管理路径,替代探索式 config 插件入口 |
|
||||
| `upstream.connect/v1` dialer mode | 1 | 第一条可用数据路径,覆盖 upstream rewrite、自定义拨号 |
|
||||
| `upstream.connect/v1` protocol-proxy mode 和 `net.Conn` 接管 | 2 | 支持完整 MC 字节流接管、initial data replay、draining |
|
||||
| MC 正版/三方登录插件、forwarding、登录后协议处理 | 2 | 由 protocol-proxy 插件实现,core 不消费认证结果 |
|
||||
| Minecraft capability manifest、protocol smoke fixture | 2 | 支撑管理页展示和后续发布门禁 |
|
||||
| source `.mcgp`、builder、构建 provenance | 3 | 源码包构建成 `plugin.so` 后复用阶段 1/2 加载路径 |
|
||||
| builder 隔离、Go/module/ABI 记录、source/build log GC | 3 | 构建失败不影响 active artifact |
|
||||
| Admin 页面基础管理闭环 | 4 | 上传、构建状态、加载、启用、禁用、删除、回滚 |
|
||||
| 配置 schema、配置快照、配置迁移入口 | 4 | 错误配置不切换 active artifact |
|
||||
| SecretStore、secret version、reload/rotation 基础 | 4 | secret 不在页面、日志、审计中明文展示 |
|
||||
| artifact rollback、config rollback | 4 | 回滚前重新执行当前基础门禁 |
|
||||
| admission policy、review、risk、warning override | 5 | 生产启用前可解释和可审计 |
|
||||
| composition conflict、scope overlap、dispatch plan | 5 | 阻断 protocol-proxy 重叠、provider 单例冲突等 |
|
||||
| preflight/self-test、benchmark release gate | 5 | 高风险插件启用前有证据 |
|
||||
| denylist、quarantine、revoke、安全公告 | 5 | 阻断受影响 artifact 的 enable/rollback |
|
||||
| metrics、custom metrics、business events、trace | 6 | 提供运行时观测和脱敏摘要 |
|
||||
| plugin logger、diagnostic package、Runbook 支撑 | 6 | 插件故障可定位、可降级、可导出摘要 |
|
||||
| background task、ExternalClient、外部依赖治理 | 6 | 周期同步、受控外联、熔断和健康状态 |
|
||||
| PluginDataStore、PluginFileStore、runtime file GC | 6 | 插件私有数据和文件资源受配额/retention 管理 |
|
||||
| route resolver/provider、route decision | 7 | 降低动态路由和外部 CMDB 集成成本 |
|
||||
| status ping、MOTD、维护模式 | 7 | 不必完整 protocol-proxy 即可定制状态响应 |
|
||||
| middleware、provider、event subscriber、rule/policy engine | 7 | 补齐 Hook 之外的生产扩展形态 |
|
||||
| Admin auth provider、外部身份绑定 | 7 | 只影响管理页登录,保留本地 break-glass |
|
||||
| `go-plugin-process` 服务启动模式、进程级卸载、fd/shm 迁移 | 8 | 未来可选,默认 `in-process` 路径仍可运行 |
|
||||
| sandbox-process、WASM、capability enforcement | 8 | 面向隔离、跨语言和轻量规则场景 |
|
||||
| `ingress.service/v1` 自定义入口服务 | 8 | 由 gateway/supervisor 管理 listener,不允许插件任意监听 |
|
||||
| 插件仓库、签名、SBOM 漏洞扫描、license policy | 8 | 仓库只导入本地 artifact,不自动启用 |
|
||||
| build-time instrumentation | 8 | 官方/组织 CI 能力,产物是 gateway binary,不是热加载插件 |
|
||||
| promotion、drift、DR drill | 4-6 | 阶段 4 建立回滚和快照,阶段 5/6 补齐门禁、diff、诊断和演练证据 |
|
||||
|
||||
## 全阶段不变量
|
||||
|
||||
这些规则从阶段 1 开始就不能被破坏:
|
||||
|
||||
- 上传包校验不能执行插件代码。
|
||||
- 生产路径统一以 `.mcgp` artifact 为单位管理。
|
||||
- 插件管理写操作必须有审计日志。
|
||||
- 启用失败不能破坏旧 dispatch table。
|
||||
- 禁用插件后,新连接不能再进入该插件。
|
||||
- 已加载 Go plugin 不能承诺真正热卸载;只能逻辑禁用或未来通过 `go-plugin-process` 退出子进程回收。
|
||||
- MC 正版/三方登录、身份映射、forwarding 和后续协议处理属于 protocol-proxy 插件,不由 gateway core 拼装。
|
||||
- 玩家名、UUID、source IP、secret、token、session response 和 packet payload 默认不进入指标标签、审计明文或普通诊断输出。
|
||||
|
||||
## 阶段推进规则
|
||||
|
||||
进入下一阶段前必须满足:
|
||||
|
||||
- 当前阶段文档中的验收项全部通过。
|
||||
- 已实现能力有最小自动化测试或可重复手动验证步骤。
|
||||
- 失败路径已验证:加载失败、启用失败、禁用、删除、重启恢复。
|
||||
- 文档已更新:用户怎么启用、怎么回滚、怎么排障。
|
||||
|
||||
如果某阶段出现实现复杂度超出预期,允许拆出子阶段,但子阶段也必须保持“当前可用”。
|
||||
125
docs/plugin-implementation-stages/phase-01-managed-binary-mvp.md
Normal file
125
docs/plugin-implementation-stages/phase-01-managed-binary-mvp.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# 阶段 1:Managed Binary Plugin MVP
|
||||
|
||||
## 目标
|
||||
|
||||
交付最小可用的受管理插件系统:管理员可以上传二进制 `.mcgp`,gateway 能校验、登记、加载、启用、禁用和删除可信 Go plugin。第一阶段只要求 `upstream.connect/v1` 的 dialer mode 可用,用于替换上游拨号或实现简单 upstream rewrite。
|
||||
|
||||
本阶段完成后,插件系统已经从探索代码进入 SQLite/Admin 管理路径,但不承诺源码包构建、完整 protocol-proxy、复杂治理和 Admin 完整页面。
|
||||
|
||||
## 可用性检查点
|
||||
|
||||
阶段结束时必须能做到:
|
||||
|
||||
- gateway 无插件时行为不变。
|
||||
- 管理员上传一个二进制 `.mcgp` 后,可以通过 Admin API 或 CLI inspect artifact。
|
||||
- 管理员可以加载并启用 `upstream-rewrite` 示例插件。
|
||||
- 命中插件 scope 的连接走插件返回的 upstream conn;不命中时走原默认 upstream。
|
||||
- 禁用插件后,新连接不再调用该插件。
|
||||
- 插件启用失败或 handler panic 不破坏旧 dispatch table。
|
||||
- 重启后,SQLite 中 enabled 的插件按 priority 恢复。
|
||||
|
||||
## 范围
|
||||
|
||||
### 包和 artifact
|
||||
|
||||
- 支持 `.mcgp` zip 上传。
|
||||
- `artifact_type=binary`。
|
||||
- `runtime.type=go-plugin`。
|
||||
- 包内必须包含 `manifest.json` 和 `plugin.so`。
|
||||
- 上传阶段只解析 zip 和 manifest,不执行插件代码。
|
||||
- 记录 artifact sha256、plugin ID、version、Go version、GOOS/GOARCH、API version、extension points 和 capabilities 摘要。
|
||||
|
||||
### 数据模型
|
||||
|
||||
实现最小表:
|
||||
|
||||
- `plugin_artifacts`
|
||||
- `plugins`
|
||||
- `plugin_operations`
|
||||
- `plugin_config_snapshots`
|
||||
- `audit_logs.metadata_json` 扩展或等价结构化审计字段
|
||||
|
||||
字段必须能表达:
|
||||
|
||||
- artifact 状态:uploaded、validated、loadable、loaded、rejected、deleted。
|
||||
- plugin desired state:enabled、disabled、deleted。
|
||||
- runtime state:not_loaded、loaded、enabled、failed、disabled。
|
||||
- desired generation 和 applied generation。
|
||||
- active artifact、desired artifact、loaded artifact 的差异。
|
||||
|
||||
### Runtime 和 dispatch
|
||||
|
||||
- 新增 Plugin Manager。
|
||||
- 保留现有 `api.Plugin` 和 `Gateway.Hook` 兼容层。
|
||||
- 将现有 `HookUpstream` 收敛为 `upstream.connect/v1` 注册路径。
|
||||
- dispatch table 使用只读快照,更新时整体替换。
|
||||
- handler 排序规则:priority 升序,priority 相同按 plugin ID。
|
||||
- handler 返回 `ErrPass` 时继续后续 handler;返回 `net.Conn` 时停止;返回普通 error 时本次连接失败。
|
||||
- handler 调用必须有 panic recover、timeout 和错误计数。
|
||||
|
||||
### Admin API / CLI
|
||||
|
||||
最小接口:
|
||||
|
||||
- 上传 artifact。
|
||||
- 查看 artifact。
|
||||
- 创建或更新 plugin desired state。
|
||||
- load。
|
||||
- enable。
|
||||
- disable。
|
||||
- delete。
|
||||
- 查看 plugin runtime state。
|
||||
- 查看 dispatch plan 摘要。
|
||||
|
||||
CLI 可以先作为开发工具,覆盖:
|
||||
|
||||
- `plugin inspect`
|
||||
- `plugin validate`
|
||||
- `plugin compat`
|
||||
|
||||
### 示例
|
||||
|
||||
提供 `examples/plugins/upstream-rewrite`:
|
||||
|
||||
- 读取 `match_host` 和 `upstream` 配置。
|
||||
- 注册 `upstream.connect/v1`。
|
||||
- 命中时 `net.Dial` 到 upstream 并返回连接。
|
||||
- 不命中时返回 pass。
|
||||
|
||||
## 明确不做
|
||||
|
||||
- 不支持 source `.mcgp` 构建。
|
||||
- 不支持 protocol-proxy mode。
|
||||
- 不支持 SecretStore。
|
||||
- 不支持完整 Admin 页面。
|
||||
- 不支持准入 review、SBOM、license 策略和仓库。
|
||||
- 不支持真正热卸载。
|
||||
- 不支持 sandbox、WASM 或 `go-plugin-process`。
|
||||
|
||||
## 实现任务
|
||||
|
||||
1. 增加 `.mcgp` 静态校验:zip slip、大小、manifest、runtime entry。
|
||||
2. 增加 manifest schema v1 的最小字段校验。
|
||||
3. 增加 Plugin Manager 和 runtime adapter 抽象,只实现 `go-plugin`。
|
||||
4. 增加 SQLite migration。
|
||||
5. 增加 desired state reconcile。
|
||||
6. 把连接路径接入 dispatch table snapshot。
|
||||
7. 实现 `upstream.connect/v1` dialer mode contract。
|
||||
8. 实现 load/enable/disable/delete API。
|
||||
9. 增加基础审计事件。
|
||||
10. 增加 upstream-rewrite 示例插件。
|
||||
|
||||
## 验收
|
||||
|
||||
- `upstream-rewrite` 能通过 `.mcgp` 上传、load、enable。
|
||||
- 启用后指定 host 连接到新 upstream。
|
||||
- disable 后新连接恢复默认 upstream。
|
||||
- plugin.Open 失败返回稳定错误,不影响其他插件和默认连接路径。
|
||||
- gateway 重启后 enabled 插件恢复。
|
||||
- `git diff --check` 和现有测试通过。
|
||||
|
||||
## 回滚策略
|
||||
|
||||
- 删除或 disable 插件即可恢复默认 upstream。
|
||||
- 如果 artifact 已加载,delete 后标记 pending cleanup,提示重启彻底清理。
|
||||
- 如果 Plugin Manager 初始化失败,gateway 应可在禁用插件系统配置下启动,并保留原有路由能力。
|
||||
106
docs/plugin-implementation-stages/phase-02-protocol-proxy-mvp.md
Normal file
106
docs/plugin-implementation-stages/phase-02-protocol-proxy-mvp.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# 阶段 2:Protocol Proxy MVP
|
||||
|
||||
## 目标
|
||||
|
||||
在阶段 1 的管理和生命周期基础上,让 `upstream.connect/v1` 支持 protocol-proxy mode。插件可以返回自管 `net.Conn`,gateway 将已读取的 initial handshake bytes 回放给该连接,并把后续客户端字节转发给插件 endpoint。
|
||||
|
||||
本阶段使 MC 正版/三方登录插件具备技术可行性:登录、身份映射、forwarding 和登录后的协议处理都由插件完成,gateway core 只负责连接交接和治理。
|
||||
|
||||
## 可用性检查点
|
||||
|
||||
阶段结束时必须能做到:
|
||||
|
||||
- dialer mode 仍可用。
|
||||
- protocol-proxy 插件可以接管完整 MC 字节流。
|
||||
- 初始 handshake 不丢失、不重复。
|
||||
- protocol-proxy 插件禁用后,新连接不再进入插件;已有连接进入 draining 或按管理员操作 force close。
|
||||
- `mc-auth-proxy` 示例至少能跑通一个登录失败响应或简单 session fixture。
|
||||
|
||||
## 范围
|
||||
|
||||
### `net.Conn` 接管契约
|
||||
|
||||
实现:
|
||||
|
||||
- `UpstreamConnectRequest.InitialData` 复制语义。
|
||||
- returned conn 初始写入 deadline。
|
||||
- 初始写入失败后的关闭和错误记录。
|
||||
- 双向 copy、half-close 退化、字节数统计、耗时统计。
|
||||
- active proxy connection 计数。
|
||||
- protocol-proxy 连接 draining 状态。
|
||||
|
||||
### 请求字段
|
||||
|
||||
补齐 request 字段:
|
||||
|
||||
- connection ID。
|
||||
- trace ID。
|
||||
- source addr。
|
||||
- normalized server host 和 raw server host。
|
||||
- protocol version。
|
||||
- next state。
|
||||
- route ID、route tags、upstream raw/protocol/address。
|
||||
- transport、service name、listener port。
|
||||
|
||||
字段新增必须只追加,不改变阶段 1 语义。
|
||||
|
||||
### 示例插件
|
||||
|
||||
提供 `examples/plugins/mc-auth-proxy` 初版:
|
||||
|
||||
- 注册 `upstream.connect/v1`。
|
||||
- 使用 `net.Pipe` 或等价 endpoint。
|
||||
- 读取 handshake/login start。
|
||||
- 对不支持或 fixture 失败场景返回 login disconnect/kick。
|
||||
- 连接 backend 并做最小透明转发。
|
||||
- 通过事件或日志上报低基数失败原因。
|
||||
|
||||
本阶段不要求完整生产级 Mojang/Yggdrasil 实现,但示例结构必须能承载后续认证源。
|
||||
|
||||
### Minecraft 能力声明
|
||||
|
||||
manifest 支持 `minecraft` 字段:
|
||||
|
||||
- protocol versions。
|
||||
- states。
|
||||
- auth modes。
|
||||
- forwarding supported/default。
|
||||
- unsupported policy。
|
||||
- modded 声明。
|
||||
|
||||
Admin API 可以先展示摘要,不要求完整 UI。
|
||||
|
||||
## 明确不做
|
||||
|
||||
- 不让 gateway core 解析 login/encryption/session。
|
||||
- 不让插件返回 `AuthResult` 给 core。
|
||||
- 不实现 `auth.provider/v1`。
|
||||
- 不做 play 阶段 packet filter。
|
||||
- 不做真实客户端大规模压测门禁。
|
||||
|
||||
## 实现任务
|
||||
|
||||
1. 定义 `UpstreamConnectRequest` 稳定 struct。
|
||||
2. 增加 `ErrPass`、`ErrBlocked` 和普通 error 行为。
|
||||
3. 实现 initial data replay。
|
||||
4. 实现 protocol-proxy connection lifecycle。
|
||||
5. 实现 draining 和 force close API。
|
||||
6. 增加 protocol-proxy metrics。
|
||||
7. 增加 Minecraft capability manifest schema。
|
||||
8. 增加 mc-auth-proxy 示例。
|
||||
9. 增加 protocol smoke test helper。
|
||||
|
||||
## 验收
|
||||
|
||||
- protocol-proxy 示例能读取 gateway 已解析前的完整 handshake bytes。
|
||||
- 初始包只被插件处理一次。
|
||||
- 插件返回不可读 conn 时,连接路径不会永久阻塞。
|
||||
- 插件 panic 只影响当前连接。
|
||||
- disable 后新连接不再进入插件。
|
||||
- 文档明确 MC 登录业务完全属于插件。
|
||||
|
||||
## 回滚策略
|
||||
|
||||
- disable protocol-proxy 插件恢复默认 upstream。
|
||||
- 对已有 protocol-proxy 连接,默认 drain;必要时 force close。
|
||||
- 如果 protocol-proxy 功能引发问题,可以保留阶段 1 dialer mode 插件能力。
|
||||
@@ -0,0 +1,112 @@
|
||||
# 阶段 3:Source Package Builder
|
||||
|
||||
## 目标
|
||||
|
||||
支持 source `.mcgp`。管理员可以上传源码包,由受控 builder 构建出最终 `plugin.so` artifact,再进入阶段 1/2 已经可用的加载和启用流程。
|
||||
|
||||
本阶段解决开发者分发源码包、记录构建环境和产物 provenance 的问题。构建失败不得影响当前 active 插件。
|
||||
|
||||
## 可用性检查点
|
||||
|
||||
阶段结束时必须能做到:
|
||||
|
||||
- 上传 binary `.mcgp` 的路径不受影响。
|
||||
- 上传 source `.mcgp` 后创建 build job。
|
||||
- build 成功后生成新的 binary artifact,可 load/enable。
|
||||
- build 失败只记录错误和日志摘要,不改变当前 active artifact。
|
||||
- 管理员能看到 source sha256、builder、Go version、module list 和 artifact sha256。
|
||||
|
||||
## 范围
|
||||
|
||||
### Source 包格式
|
||||
|
||||
source `.mcgp` 必须包含:
|
||||
|
||||
- `manifest.json`
|
||||
- `go.mod`
|
||||
- build entry
|
||||
- 源码文件
|
||||
|
||||
可选:
|
||||
|
||||
- `go.sum`
|
||||
- `vendor/`
|
||||
- README、LICENSE、SBOM
|
||||
|
||||
不执行包内任意 shell 脚本。构建命令由 gateway/builder 固定生成。
|
||||
|
||||
### Builder
|
||||
|
||||
支持两种 builder:
|
||||
|
||||
- `local-process`:开发模式。
|
||||
- `container`:生产推荐。
|
||||
|
||||
生产默认推荐 container builder 或外部 CI。gateway 主进程不得直接执行 `go build`。
|
||||
|
||||
固定构建维度:
|
||||
|
||||
- Go version。
|
||||
- GOOS/GOARCH/GOAMD64/GOARM64。
|
||||
- CGO。
|
||||
- build tags。
|
||||
- SDK module version。
|
||||
- GOPROXY/GONOSUMDB/GOPRIVATE 策略。
|
||||
- vendor required。
|
||||
|
||||
### Provenance
|
||||
|
||||
记录:
|
||||
|
||||
- source package sha256。
|
||||
- artifact sha256。
|
||||
- builder type/image/version。
|
||||
- Go version。
|
||||
- `go list -m -json all` 摘要。
|
||||
- `go version -m` 摘要。
|
||||
- ABI fingerprint。
|
||||
- build log 摘要。
|
||||
- build start/end/duration。
|
||||
|
||||
### GC
|
||||
|
||||
实现:
|
||||
|
||||
- source package 保留策略。
|
||||
- build log 保留策略。
|
||||
- artifact GC candidate。
|
||||
- active/desired/snapshot referenced artifact 不可被 GC。
|
||||
|
||||
## 明确不做
|
||||
|
||||
- 不把源码构建等同于运行时沙箱。
|
||||
- 不支持自定义构建脚本。
|
||||
- 不强制签名。
|
||||
- 不实现远程插件仓库。
|
||||
|
||||
## 实现任务
|
||||
|
||||
1. 扩展 `.mcgp` 校验支持 `artifact_type=source`。
|
||||
2. 增加 `plugin_builds` 状态机。
|
||||
3. 实现 build operation 和取消/重试。
|
||||
4. 实现 local-process builder。
|
||||
5. 实现 container builder 接口或预留适配。
|
||||
6. 构建后执行 metadata ABI 校验。
|
||||
7. 构建成功写入 `plugin_artifacts`。
|
||||
8. 构建失败保存脱敏日志摘要。
|
||||
9. 增加 build API/CLI。
|
||||
10. 更新示例插件,支持 source package。
|
||||
|
||||
## 验收
|
||||
|
||||
- source upstream-rewrite 能构建并启用。
|
||||
- source mc-auth-proxy 能构建或至少通过编译 fixture。
|
||||
- builder Go version 不匹配时阻断启用或构建。
|
||||
- 构建日志不包含 secret、环境 token 或完整私有路径。
|
||||
- 构建失败不影响 active artifact。
|
||||
|
||||
## 回滚策略
|
||||
|
||||
- 构建产物只有 enable 后才影响流量。
|
||||
- 构建失败或产物校验失败时保留旧 artifact。
|
||||
- 如 builder 配置异常,可关闭 source package 构建,继续支持 binary `.mcgp`。
|
||||
@@ -0,0 +1,110 @@
|
||||
# 阶段 4:Admin UI、配置、Secret 和回滚
|
||||
|
||||
## 目标
|
||||
|
||||
把阶段 1 到 3 的能力做成管理员可用的管理闭环。管理页支持上传、构建状态、加载、启用、禁用、删除、配置编辑、secret 配置、配置快照和 artifact 回滚。
|
||||
|
||||
阶段结束时,普通运维不需要直接调用底层 API 才能完成插件日常管理。
|
||||
|
||||
## 可用性检查点
|
||||
|
||||
阶段结束时必须能做到:
|
||||
|
||||
- 管理员能在页面看到插件列表、artifact、runtime state 和最近错误。
|
||||
- 管理员能上传 binary/source `.mcgp`。
|
||||
- 管理员能编辑配置并执行 dry-run 校验。
|
||||
- 管理员能配置插件 secret ref,不看到 secret 明文。
|
||||
- 管理员能禁用、删除、切换 artifact 和回滚配置快照。
|
||||
- 配置错误不会切换 active artifact。
|
||||
|
||||
## 范围
|
||||
|
||||
### Admin 页面
|
||||
|
||||
列表展示:
|
||||
|
||||
- plugin ID、name、version。
|
||||
- artifact type。
|
||||
- runtime state。
|
||||
- desired state。
|
||||
- active/desired/loaded artifact。
|
||||
- extension points。
|
||||
- priority。
|
||||
- scope/rollout。
|
||||
- restart required。
|
||||
- health/最近错误。
|
||||
|
||||
详情页展示:
|
||||
|
||||
- manifest metadata。
|
||||
- Go/API/ABI 兼容信息。
|
||||
- capabilities 摘要。
|
||||
- Minecraft capability 摘要。
|
||||
- build 历史和日志摘要。
|
||||
- current config。
|
||||
- secret 状态。
|
||||
- dispatch plan。
|
||||
- active proxy connections。
|
||||
|
||||
### 配置
|
||||
|
||||
- 支持 JSON 编辑器兜底。
|
||||
- 支持 JSON Schema 基础校验。
|
||||
- 支持 `ReloadConfig()` dry-run。
|
||||
- 支持 sensitive 字段脱敏 diff。
|
||||
- 支持 config snapshot。
|
||||
- 支持 config-only rollback 和 full desired rollback。
|
||||
|
||||
### Secret
|
||||
|
||||
实现最小 SecretStore:
|
||||
|
||||
- 创建/更新 secret。
|
||||
- secret ref 校验。
|
||||
- 当前/previous version。
|
||||
- reload required/hot reload 标记。
|
||||
- secret 不进入日志、审计明文和 API 响应。
|
||||
|
||||
### 回滚
|
||||
|
||||
支持:
|
||||
|
||||
- artifact rollback。
|
||||
- config snapshot rollback。
|
||||
- rollback 前重新执行兼容性和当前基础门禁。
|
||||
- rollback 写审计。
|
||||
|
||||
## 明确不做
|
||||
|
||||
- 不做完整准入审批。
|
||||
- 不做 SBOM/license 阻断。
|
||||
- 不做外部 KMS。
|
||||
- 不做复杂声明式 UI,自定义 HTML/JS 不支持。
|
||||
- 不做 promotion bundle。
|
||||
|
||||
## 实现任务
|
||||
|
||||
1. 实现插件列表和详情页。
|
||||
2. 实现上传和构建状态 UI。
|
||||
3. 实现配置编辑、schema 校验和 dry-run。
|
||||
4. 实现 secret 状态和编辑流程。
|
||||
5. 实现 artifact rollback UI/API。
|
||||
6. 实现 config snapshot diff/rollback。
|
||||
7. 实现 restart required 展示。
|
||||
8. 实现基础 permission key 映射到 admin/member/guest。
|
||||
9. 所有写操作写审计。
|
||||
|
||||
## 验收
|
||||
|
||||
- 管理员可在 UI 上传并启用 upstream-rewrite。
|
||||
- 管理员可在 UI 上传 source package 并查看 build result。
|
||||
- 修改错误配置不会影响当前运行插件。
|
||||
- secret 在页面和审计里不显示明文。
|
||||
- rollback 到旧 artifact 后新连接使用旧版本。
|
||||
- member 只能查看状态,不能执行写操作。
|
||||
|
||||
## 回滚策略
|
||||
|
||||
- UI 出问题时保留 Admin API/CLI 操作路径。
|
||||
- 配置保存失败不改变 desired generation。
|
||||
- 回滚失败不改变当前 active state。
|
||||
@@ -0,0 +1,116 @@
|
||||
# 阶段 5:Governance And Release Gates
|
||||
|
||||
## 目标
|
||||
|
||||
把插件从“能运行”提升到“可安全进入生产”。本阶段实现准入策略、review、风险分级、冲突分析、发布门禁、preflight/self-test、benchmark 结果和安全公告响应。
|
||||
|
||||
阶段结束后,管理员可以解释一个插件为什么能启用、为什么被阻断、启用会影响哪些流量,以及如何回滚。
|
||||
|
||||
## 可用性检查点
|
||||
|
||||
阶段结束时必须能做到:
|
||||
|
||||
- 高风险插件启用前需要 review。
|
||||
- protocol-proxy scope 重叠会阻断启用。
|
||||
- 必需 secret、依赖、feature 缺失会阻断启用。
|
||||
- preflight/self-test 失败会阻断或进入 warning。
|
||||
- benchmark 结果超过阈值会进入 warning/blocking。
|
||||
- denylist/advisory 命中后不能 rollback 到受影响 artifact。
|
||||
|
||||
## 范围
|
||||
|
||||
### 准入策略
|
||||
|
||||
实现:
|
||||
|
||||
- dev/staging/prod profile。
|
||||
- risk level。
|
||||
- policy snapshot hash。
|
||||
- warning override TTL。
|
||||
- review 记录绑定 artifact/config/scope/rollout/runtime limits/features/policy hash。
|
||||
- denylist、quarantine、revoke。
|
||||
|
||||
### 冲突分析
|
||||
|
||||
实现:
|
||||
|
||||
- scope overlap。
|
||||
- protocol-proxy singleton 冲突。
|
||||
- provider singleton 冲突。
|
||||
- middleware ordering cycle。
|
||||
- shadowed handler warning。
|
||||
- dispatch plan API/UI。
|
||||
|
||||
### Preflight 和 SelfTest
|
||||
|
||||
实现通用门禁:
|
||||
|
||||
- config。
|
||||
- secret。
|
||||
- external dependency 声明。
|
||||
- runtime limits。
|
||||
- scope/rollout。
|
||||
- Minecraft capability。
|
||||
- backend forwarding warning。
|
||||
|
||||
插件实现 `PreflightChecker` 或 `SelfTester` 时复用结果。
|
||||
|
||||
### 性能门禁
|
||||
|
||||
记录:
|
||||
|
||||
- benchmark profile。
|
||||
- P95/P99。
|
||||
- error rate。
|
||||
- active proxy capacity。
|
||||
- baseline diff。
|
||||
|
||||
默认策略:
|
||||
|
||||
- 退化超过 20% warning。
|
||||
- 退化超过 50% 或超过 runtime limit blocking。
|
||||
|
||||
### 安全公告
|
||||
|
||||
支持本地 advisory:
|
||||
|
||||
- artifact sha256 match。
|
||||
- plugin/version range match。
|
||||
- SBOM dependency match。
|
||||
- recommended action。
|
||||
- fixed version。
|
||||
- mitigation status。
|
||||
|
||||
## 明确不做
|
||||
|
||||
- 不强制签名。
|
||||
- 不接外部漏洞库作为强依赖。
|
||||
- 不做双人审批。
|
||||
- 不自动升级或自动启用仓库版本。
|
||||
|
||||
## 实现任务
|
||||
|
||||
1. 实现 policy engine。
|
||||
2. 实现 review API/UI。
|
||||
3. 实现 denylist/quarantine/revoke。
|
||||
4. 实现 conflict check。
|
||||
5. 实现 preflight API 和结果存储。
|
||||
6. 实现 self-test profile。
|
||||
7. 实现 benchmark result 存储和门禁。
|
||||
8. 实现 security advisory import/rescan/ack。
|
||||
9. 将门禁接入 enable、rollback、promotion import apply。
|
||||
|
||||
## 验收
|
||||
|
||||
- 未 review 的高风险 protocol-proxy 插件不能在 prod profile 启用。
|
||||
- 两个同 scope protocol-proxy 插件不能同时启用。
|
||||
- required feature 缺失返回 `feature_missing`。
|
||||
- secret 缺失阻断启用。
|
||||
- advisory revoke 后不能 rollback 到受影响 artifact。
|
||||
- warning override 过期后重新阻断相关操作。
|
||||
|
||||
## 回滚策略
|
||||
|
||||
- policy 变更不应立即删除运行中插件;先标记 drift/review_required 或 quarantine。
|
||||
- quarantine 从 dispatch table 移除插件,新连接不进入;已有 protocol-proxy 连接按策略 drain/force close。
|
||||
- 管理员可以回滚到未受阻断的旧 artifact。
|
||||
@@ -0,0 +1,123 @@
|
||||
# 阶段 6:Observability And Operations
|
||||
|
||||
## 目标
|
||||
|
||||
补齐生产运行所需的观测和运维能力:metrics、业务事件、trace、日志、诊断包、后台任务、plugin_data、PluginFileStore、外部依赖治理、GC 和 Runbook。
|
||||
|
||||
阶段结束后,插件出问题时管理员能定位、降级、清理和恢复,而不是只能查看 gateway 日志。
|
||||
|
||||
## 可用性检查点
|
||||
|
||||
阶段结束时必须能做到:
|
||||
|
||||
- 管理员能看到 handler calls、duration、panic、timeout、active proxy connections。
|
||||
- 插件可以上报脱敏业务事件和自定义指标。
|
||||
- trace 能关联 connection、plugin handler、external dependency、backend dial。
|
||||
- 插件能注册 interval/manual background task。
|
||||
- 插件能使用 PluginDataStore 和 PluginFileStore,并受配额限制。
|
||||
- 管理员能执行 plugin_data/file/log/artifact GC dry-run 和清理。
|
||||
|
||||
## 范围
|
||||
|
||||
### Metrics
|
||||
|
||||
实现:
|
||||
|
||||
- plugin handler calls。
|
||||
- duration histogram。
|
||||
- errors/panic/timeout。
|
||||
- active calls。
|
||||
- active proxy connections。
|
||||
- build duration/failures。
|
||||
- external dependency requests/duration/inflight/circuit state。
|
||||
- event delivery queue/drop/dead letter。
|
||||
|
||||
### Events 和 custom metrics
|
||||
|
||||
- 插件 manifest 声明 event schema。
|
||||
- `EmitEvent` 接收低基数字段。
|
||||
- 未声明或高基数字段拒绝或 drop。
|
||||
- 最近事件摘要保留。
|
||||
- custom metric schema 和低基数 label 限制。
|
||||
|
||||
### Tracing
|
||||
|
||||
- gateway 生成 connection ID 和 trace ID。
|
||||
- SDK 通过 context 传递。
|
||||
- 日志带 trace/connection ID。
|
||||
- trace 摘要脱敏。
|
||||
- 默认不向第三方依赖注入 `traceparent`,除非策略允许。
|
||||
|
||||
### Background Task
|
||||
|
||||
- interval/manual。
|
||||
- run-on-start。
|
||||
- jitter。
|
||||
- timeout。
|
||||
- non-reentrant。
|
||||
- manual trigger 权限和 confirm token。
|
||||
- last/next run、skipped、consecutive failures。
|
||||
|
||||
### Data 和 files
|
||||
|
||||
PluginDataStore:
|
||||
|
||||
- schema version。
|
||||
- data class。
|
||||
- quota。
|
||||
- retention。
|
||||
- exportable 标记。
|
||||
- GC。
|
||||
|
||||
PluginFileStore:
|
||||
|
||||
- resources readonly。
|
||||
- runtime data/cache/tmp/log/diagnostic。
|
||||
- path traversal 防护。
|
||||
- quota。
|
||||
- retention。
|
||||
- orphaned dir 检测。
|
||||
|
||||
### External dependencies
|
||||
|
||||
- endpoint、purpose、required、timeout、retry、fail policy。
|
||||
- `ExternalClient` 受控 HTTP/TCP 调用。
|
||||
- health check。
|
||||
- circuit breaker。
|
||||
- data classes。
|
||||
- 最近错误摘要。
|
||||
|
||||
## 明确不做
|
||||
|
||||
- 不承诺 native plugin 无法绕过 ExternalClient。
|
||||
- 不默认开启 Prometheus/OTel exporter 的完整外部集成。
|
||||
- 不保存完整 packet payload、secret、token、session response。
|
||||
|
||||
## 实现任务
|
||||
|
||||
1. 实现 metrics 内部模型和 Admin API。
|
||||
2. 实现 business event/custom metric SDK。
|
||||
3. 实现 trace summary。
|
||||
4. 实现 plugin logger 和日志摘要。
|
||||
5. 实现 background task 注册和状态。
|
||||
6. 实现 PluginDataStore。
|
||||
7. 实现 PluginFileStore。
|
||||
8. 实现 ExternalClient。
|
||||
9. 实现 diagnostic package。
|
||||
10. 实现 GC APIs 和 Runbook。
|
||||
|
||||
## 验收
|
||||
|
||||
- mc-auth-proxy 示例能上报 `auth.success`/`auth.failure` 摘要。
|
||||
- session server 调用通过 ExternalClient 记录 latency 和错误。
|
||||
- background task 超时不会阻塞连接路径。
|
||||
- plugin_data 超配额时写入失败且不会无限增长 SQLite。
|
||||
- 诊断包不包含 secret 明文和完整 packet。
|
||||
- GC dry-run 能展示将清理的对象和大小。
|
||||
|
||||
## 回滚策略
|
||||
|
||||
- exporter 失败不能影响连接路径。
|
||||
- event 队列满默认 drop,不阻塞主流程。
|
||||
- background task 可取消;disable 插件时任务停止。
|
||||
- plugin_data/file GC 先 dry-run,清理操作写审计。
|
||||
@@ -0,0 +1,116 @@
|
||||
# 阶段 7:Extension Ecosystem
|
||||
|
||||
## 目标
|
||||
|
||||
在稳定的插件主路径上扩展生态能力:route resolver、status ping、event subscriber、provider、middleware、rule/policy engine、Admin auth provider 和更多官方示例插件。
|
||||
|
||||
阶段结束后,用户可以不写完整 protocol-proxy,也能用更低成本 extension point 完成常见运维需求。
|
||||
|
||||
## 可用性检查点
|
||||
|
||||
阶段结束时必须能做到:
|
||||
|
||||
- 官方 rule/policy 插件能完成 host rewrite、IP 黑白名单、简单限流或维护模式。
|
||||
- route provider 能从外部源或缓存产生可解释 route decision。
|
||||
- status ping 插件能自定义 MOTD/版本提示。
|
||||
- event subscriber 能异步投递审计或插件事件。
|
||||
- Admin auth provider 如果启用,不影响 MC 连接路径,本地 admin break-glass 保留。
|
||||
|
||||
## 范围
|
||||
|
||||
### Route
|
||||
|
||||
- `route.resolve/v1`
|
||||
- `route.resolver/v1`
|
||||
- route decision schema。
|
||||
- provider cache。
|
||||
- SQLite fallback。
|
||||
- refresh action。
|
||||
|
||||
### Status
|
||||
|
||||
- `status.ping/v1`
|
||||
- MOTD。
|
||||
- favicon。
|
||||
- online/max players。
|
||||
- version text。
|
||||
- maintenance window。
|
||||
|
||||
### Middleware
|
||||
|
||||
预留并选择性实现:
|
||||
|
||||
- `connection.filter/v1`
|
||||
- `handshake.filter/v1`
|
||||
|
||||
必须有确定顺序、timeout、panic recover 和 fail policy。
|
||||
|
||||
### Provider
|
||||
|
||||
- provider singleton。
|
||||
- priority/fallback。
|
||||
- plugin dependencies。
|
||||
- `auth.provider/v1` 只作为插件间认证来源复用,不给 gateway core 组装 MC 登录流程。
|
||||
|
||||
### Event subscriber
|
||||
|
||||
- best_effort。
|
||||
- at_least_once。
|
||||
- queue。
|
||||
- retry。
|
||||
- dead letter。
|
||||
- replay/drop action。
|
||||
|
||||
### Rule / Policy Engine
|
||||
|
||||
官方插件形式提供:
|
||||
|
||||
- host rewrite。
|
||||
- source CIDR allow/deny。
|
||||
- simple rate limit。
|
||||
- maintenance mode。
|
||||
- upstream rewrite。
|
||||
|
||||
### Admin Auth Provider
|
||||
|
||||
预留或实现:
|
||||
|
||||
- OIDC。
|
||||
- LDAP。
|
||||
- external identity binding。
|
||||
- break-glass local admin。
|
||||
- gateway-issued session。
|
||||
|
||||
## 明确不做
|
||||
|
||||
- 不开放 play 阶段 packet filter 作为默认生产能力。
|
||||
- 不让 `auth.provider/v1` 进入 gateway core MC 登录流水线。
|
||||
- 不允许插件自定义 Admin 权限绕过 gateway 权限模型。
|
||||
- 不允许插件注入 Admin 自定义 HTML/JS。
|
||||
|
||||
## 实现任务
|
||||
|
||||
1. 实现 route decision 模型。
|
||||
2. 实现 route provider cache 和 refresh action。
|
||||
3. 实现 status ping extension point。
|
||||
4. 实现 event subscriber delivery。
|
||||
5. 实现 provider registry。
|
||||
6. 实现 rule/policy 官方插件。
|
||||
7. 预留或实现 Admin auth provider。
|
||||
8. 增加示例插件和 conformance fixture。
|
||||
|
||||
## 验收
|
||||
|
||||
- route provider 返回 override/fallback/reject/pass 都能在 Admin 解释。
|
||||
- 外部 route source 不可用时能使用 cache 或 SQLite fallback。
|
||||
- status 插件能按 host 返回不同 MOTD。
|
||||
- event subscriber 失败不影响连接路径。
|
||||
- rule 插件配置错误不会破坏默认路由。
|
||||
- Admin auth provider 不可用时,本地 admin 仍可登录。
|
||||
|
||||
## 回滚策略
|
||||
|
||||
- route provider disable 后恢复 SQLite route snapshot。
|
||||
- status 插件 disable 后恢复默认 status。
|
||||
- event subscriber disable 后只停止外部投递,不删除本地审计。
|
||||
- rule 插件冲突时通过 priority/scope 修复或禁用。
|
||||
@@ -0,0 +1,180 @@
|
||||
# 阶段 8:Future Runtimes And Distribution
|
||||
|
||||
## 目标
|
||||
|
||||
引入最终设计中的未来能力,同时保持前七个阶段的默认路径可用。包括 `go-plugin-process`、sandbox-process、WASM、ingress service、插件仓库、签名、SBOM 漏洞扫描、许可证策略和 build-time instrumentation。
|
||||
|
||||
本阶段由多个可选子阶段组成。每个子阶段都必须可以单独启用或回滚,不能要求一次性切换所有 runtime。
|
||||
|
||||
## 可用性检查点
|
||||
|
||||
阶段结束时必须能做到:
|
||||
|
||||
- 默认 `in-process go-plugin` 路径仍然可用。
|
||||
- 管理后台可以配置插件服务启动模式 desired value,并明确 restart required。
|
||||
- `go-plugin-process` 至少支持 drain-only。
|
||||
- sandbox-process/WASM 如果启用,capabilities 能被强制或阻断启用。
|
||||
- 仓库导入只生成本地 artifact,不自动启用生产流量。
|
||||
- 签名、SBOM、license 和 advisory 策略能参与准入结果。
|
||||
|
||||
## 子阶段 A:go-plugin-process
|
||||
|
||||
实现:
|
||||
|
||||
- gateway 插件服务启动模式:`in-process`、`go-plugin-process`、`sandbox-process`。
|
||||
- Admin 系统配置项 `plugin_service.desired_mode`。
|
||||
- 启动时读取 desired mode,校验后写入 `plugin_service.active_mode` 和 `applied_at`。
|
||||
- `restart_required` 由 desired/active 差异推导。
|
||||
- 环境级连接迁移开关:`drain-only`、`fd-live`、`fd-live-shm`。
|
||||
- plugin-host supervisor。
|
||||
- 子进程 lifecycle。
|
||||
- UDS control channel。
|
||||
- `drain-only` 进程级卸载。
|
||||
- 可选 `fd-live`。
|
||||
- 可选 `fd-live-shm`。
|
||||
- shared memory state schema。
|
||||
- `quiesce/snapshot/restore` conformance。
|
||||
|
||||
默认:
|
||||
|
||||
- `in-process`。
|
||||
- `active_mode` 只由 gateway 启动流程写入。
|
||||
- `go-plugin-process` 切换需要重启。
|
||||
- live migration 默认关闭。
|
||||
- 迁移默认模式为 `drain-only`。
|
||||
|
||||
验收:
|
||||
|
||||
- Admin 修改 `plugin_service.desired_mode` 后不立即切换当前进程,页面展示 restart required。
|
||||
- 重启后 gateway 按 desired mode 创建对应 RuntimeAdapter 或 plugin-host supervisor,并更新 active mode。
|
||||
- `go-plugin-process` 模式下启用 upstream-rewrite。
|
||||
- disable 后旧 plugin-host drain 并退出。
|
||||
- 子进程退出后 `.so` 和 Go heap 被 OS 回收。
|
||||
- crash loop 不影响 Admin 主进程。
|
||||
|
||||
## 子阶段 B:sandbox-process
|
||||
|
||||
实现:
|
||||
|
||||
- control RPC。
|
||||
- supervisor。
|
||||
- crash loop policy。
|
||||
- secret RPC/handle。
|
||||
- filesystem/network/env/cpu/memory capability enforcement。
|
||||
- stream relay 或 `stream.proxy/v1`。
|
||||
|
||||
验收:
|
||||
|
||||
- sandbox 插件崩溃不导致 gateway 崩溃。
|
||||
- 无法强制 required capability 时阻断启用。
|
||||
- secret 不通过长期环境变量注入。
|
||||
|
||||
## 子阶段 C:WASM
|
||||
|
||||
实现:
|
||||
|
||||
- WASM host ABI。
|
||||
- route/rule/config validate extension point。
|
||||
- memory/time limits。
|
||||
- no file/no network 默认策略。
|
||||
|
||||
验收:
|
||||
|
||||
- WASM rule 插件可以返回 allow/deny/rewrite。
|
||||
- 超时或内存超限只影响当前调用。
|
||||
- WASM 插件不能访问未授权 secret/network。
|
||||
|
||||
## 子阶段 D:ingress service
|
||||
|
||||
实现:
|
||||
|
||||
- `ingress.service/v1` schema。
|
||||
- service supervisor。
|
||||
- listener ownership by gateway。
|
||||
- port conflict check。
|
||||
- TLS/secret refs。
|
||||
- health and drain。
|
||||
|
||||
验收:
|
||||
|
||||
- 插件声明入口服务后,由 gateway 创建 listener。
|
||||
- disable 后停止接收新连接并 drain。
|
||||
- 端口冲突阻断启用。
|
||||
|
||||
## 子阶段 E:repository and supply chain
|
||||
|
||||
实现:
|
||||
|
||||
- official/internal/file/url repository index。
|
||||
- repository trust policy。
|
||||
- artifact download to local store。
|
||||
- signature verification。
|
||||
- SBOM vulnerability scan。
|
||||
- license allowlist/denylist。
|
||||
- advisory feed sync。
|
||||
- update availability。
|
||||
|
||||
验收:
|
||||
|
||||
- 仓库候选版本导入后仍需本地 review。
|
||||
- 仓库删除版本不删除本地 artifact。
|
||||
- denylist/advisory 仍阻断 rollback 和 promotion apply。
|
||||
|
||||
## 子阶段 F:build-time instrumentation
|
||||
|
||||
实现:
|
||||
|
||||
- instrumentation manifest。
|
||||
- official/organization CI profile。
|
||||
- generated diff hash。
|
||||
- provenance。
|
||||
- conformance/benchmark/smoke gate。
|
||||
|
||||
验收:
|
||||
|
||||
- 插桩产物作为 gateway binary 发布,不进入 plugin artifact hot-load lifecycle。
|
||||
- Admin 展示 instrumentation metadata。
|
||||
- 插桩影响连接路径时 Runbook 说明回滚方式。
|
||||
|
||||
## 明确不做
|
||||
|
||||
- 不把 `go-plugin-process` 当成不可信 sandbox。
|
||||
- 不把 fd 迁移当成跨平台通用能力。
|
||||
- 不把 WASM 用于完整 MC protocol-proxy。
|
||||
- 不让仓库自动启用生产插件。
|
||||
- 不允许普通上传包携带插桩规则直接改 gateway binary。
|
||||
|
||||
## 实现任务
|
||||
|
||||
本阶段按子阶段逐个实施。每个子阶段都必须满足:
|
||||
|
||||
1. 默认 `in-process go-plugin` 路径不回归。
|
||||
2. 新 runtime 或供应链能力可通过 feature flag、service mode 或 policy profile 关闭。
|
||||
3. Admin/API 能展示当前 active 状态、desired 状态和失败原因。
|
||||
4. promotion import 不能自动启用目标环境不支持的 runtime 或策略。
|
||||
5. conformance 覆盖新增 manifest 字段、feature key、错误码和回滚路径。
|
||||
|
||||
建议顺序:
|
||||
|
||||
1. 先实现 service mode 数据模型和 Admin 展示。
|
||||
2. 再实现 `go-plugin-process` 的 `drain-only`。
|
||||
3. 再评估 `fd-live` 和 `fd-live-shm`。
|
||||
4. 然后引入 sandbox-process 和 WASM。
|
||||
5. 最后引入仓库、签名、SBOM 扫描和 build-time instrumentation。
|
||||
|
||||
## 验收
|
||||
|
||||
- 未开启任何 future runtime 时,阶段 1 到阶段 7 的插件能力仍通过回归验证。
|
||||
- service mode 从 `in-process` 切换到 `go-plugin-process` 时明确提示 restart required。
|
||||
- `go-plugin-process` 子进程 crash 不导致 Admin 主进程退出,并能展示 crash loop 状态。
|
||||
- sandbox-process required capability 无法强制时,启用被阻断而不是降级为只审计。
|
||||
- WASM 插件超时、panic 或内存超限只影响当前调用。
|
||||
- repository import 只生成本地 artifact,并重新进入本地准入、review、enable 流程。
|
||||
- build-time instrumentation 产物不会出现在 runtime plugin enable/disable 列表中。
|
||||
|
||||
## 回滚策略
|
||||
|
||||
- runtime mode 切换失败时回到 `in-process`。
|
||||
- sandbox/WASM 插件失败时禁用对应 plugin,不影响 `go-plugin` 插件。
|
||||
- repository 功能失败不影响本地 artifact。
|
||||
- build-time instrumentation 回滚到上一 gateway binary。
|
||||
7894
docs/plugin-system-design.md
Normal file
7894
docs/plugin-system-design.md
Normal file
File diff suppressed because it is too large
Load Diff
23
examples/plugins/upstream-rewrite/README.md
Normal file
23
examples/plugins/upstream-rewrite/README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Upstream Rewrite Plugin
|
||||
|
||||
This example registers `upstream.connect/v1` in dialer mode. When `match_host`
|
||||
matches either the Minecraft hostname or the resolved upstream string, it dials
|
||||
the configured `upstream` and returns that connection. Non-matching connections
|
||||
return `api.ErrPass`.
|
||||
|
||||
Build and package:
|
||||
|
||||
```sh
|
||||
./build.sh
|
||||
```
|
||||
|
||||
The package is written to `dist/upstream-rewrite.mcgp`.
|
||||
|
||||
Example config JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"match_host": "play.example",
|
||||
"upstream": "127.0.0.1:25566"
|
||||
}
|
||||
```
|
||||
12
examples/plugins/upstream-rewrite/build.sh
Executable file
12
examples/plugins/upstream-rewrite/build.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
mkdir -p dist
|
||||
go build -buildmode=plugin -o dist/plugin.so .
|
||||
go run ./cmd/render-manifest > dist/manifest.json
|
||||
cp README.md dist/README.md
|
||||
(
|
||||
cd dist
|
||||
rm -f upstream-rewrite.mcgp
|
||||
zip -q upstream-rewrite.mcgp manifest.json plugin.so README.md
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func main() {
|
||||
manifest := map[string]any{
|
||||
"schema_version": "mc-gateway.plugin/v1",
|
||||
"id": "upstream-rewrite",
|
||||
"name": "Upstream Rewrite",
|
||||
"version": "0.1.0",
|
||||
"description": "Rewrite selected upstream targets before dialing.",
|
||||
"artifact_type": "binary",
|
||||
"runtime": map[string]any{
|
||||
"type": "go-plugin",
|
||||
"entry": "plugin.so",
|
||||
"entry_symbol": "Plugin",
|
||||
"metadata_symbol": "MCGatewayPluginMetadata",
|
||||
},
|
||||
"api_version": "plugin-api/v1",
|
||||
"sdk_module": "github.com/tursom/mc-gateway/plugin/api",
|
||||
"sdk_module_version": "v0.1.0",
|
||||
"go_version": runtime.Version(),
|
||||
"go_os": runtime.GOOS,
|
||||
"go_arch": runtime.GOARCH,
|
||||
"extension_points": []map[string]any{
|
||||
{"type": "hook", "key": "upstream.connect/v1"},
|
||||
},
|
||||
"capabilities": map[string]any{
|
||||
"extension_points": []string{"upstream.connect/v1"},
|
||||
"network": map[string]any{"outbound": []string{"tcp:*:*"}},
|
||||
"filesystem": map[string]any{"read": []string{}, "write": []string{}},
|
||||
"env": []string{},
|
||||
},
|
||||
"runtime_limits": map[string]any{
|
||||
"handler_timeout_ms": 3000,
|
||||
},
|
||||
"config_schema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"match_host": map[string]any{"type": "string"},
|
||||
"upstream": map[string]any{"type": "string"},
|
||||
},
|
||||
"required": []string{"upstream"},
|
||||
},
|
||||
}
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(manifest); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
7
examples/plugins/upstream-rewrite/go.mod
Normal file
7
examples/plugins/upstream-rewrite/go.mod
Normal file
@@ -0,0 +1,7 @@
|
||||
module github.com/tursom/mc-gateway/examples/plugins/upstream-rewrite
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require github.com/tursom/mc-gateway v0.0.0
|
||||
|
||||
replace github.com/tursom/mc-gateway => ../../..
|
||||
99
examples/plugins/upstream-rewrite/main.go
Normal file
99
examples/plugins/upstream-rewrite/main.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"runtime"
|
||||
|
||||
"github.com/tursom/mc-gateway/plugin/api"
|
||||
)
|
||||
|
||||
type PluginImpl struct {
|
||||
api.AbstractPlugin
|
||||
config Config
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
MatchHost string `json:"match_host"`
|
||||
Upstream string `json:"upstream"`
|
||||
}
|
||||
|
||||
func Plugin() api.Plugin {
|
||||
return &PluginImpl{}
|
||||
}
|
||||
|
||||
func MCGatewayPluginMetadata() string {
|
||||
return manifestJSON
|
||||
}
|
||||
|
||||
func (p *PluginImpl) NewConfigObj() any {
|
||||
return &Config{}
|
||||
}
|
||||
|
||||
func (p *PluginImpl) ReloadConfig(config any) error {
|
||||
if cfg, ok := config.(*Config); ok {
|
||||
p.config = *cfg
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PluginImpl) Init(gateway api.Gateway) error {
|
||||
return api.RegisterHookHandler(
|
||||
gateway,
|
||||
api.HookUpstreamConnect,
|
||||
func(req api.UpstreamConnectRequest) bool {
|
||||
return p.config.MatchHost == "" || req.Host == p.config.MatchHost || req.Upstream == p.config.MatchHost
|
||||
},
|
||||
func(req api.UpstreamConnectRequest) (net.Conn, error) {
|
||||
if p.config.Upstream == "" {
|
||||
return nil, api.ErrPass
|
||||
}
|
||||
if p.config.MatchHost != "" && req.Host != p.config.MatchHost && req.Upstream != p.config.MatchHost {
|
||||
return nil, api.ErrPass
|
||||
}
|
||||
return net.Dial("tcp", p.config.Upstream)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
var manifestJSON = compactJSON(map[string]any{
|
||||
"schema_version": "mc-gateway.plugin/v1",
|
||||
"id": "upstream-rewrite",
|
||||
"name": "Upstream Rewrite",
|
||||
"version": "0.1.0",
|
||||
"description": "Rewrite selected upstream targets before dialing.",
|
||||
"artifact_type": "binary",
|
||||
"runtime": map[string]any{
|
||||
"type": "go-plugin",
|
||||
"entry": "plugin.so",
|
||||
"entry_symbol": "Plugin",
|
||||
"metadata_symbol": "MCGatewayPluginMetadata",
|
||||
},
|
||||
"api_version": "plugin-api/v1",
|
||||
"sdk_module": "github.com/tursom/mc-gateway/plugin/api",
|
||||
"go_version": runtime.Version(),
|
||||
"go_os": runtime.GOOS,
|
||||
"go_arch": runtime.GOARCH,
|
||||
"extension_points": []map[string]any{
|
||||
{"type": "hook", "key": "upstream.connect/v1"},
|
||||
},
|
||||
"capabilities": map[string]any{
|
||||
"extension_points": []string{"upstream.connect/v1"},
|
||||
"network": map[string]any{"outbound": []string{"tcp:*:*"}},
|
||||
},
|
||||
"runtime_limits": map[string]any{
|
||||
"handler_timeout_ms": 3000,
|
||||
},
|
||||
"config_schema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"match_host": map[string]any{"type": "string"},
|
||||
"upstream": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
func compactJSON(value any) string {
|
||||
data, _ := json.Marshal(value)
|
||||
return string(data)
|
||||
}
|
||||
40
examples/plugins/upstream-rewrite/manifest.json
Normal file
40
examples/plugins/upstream-rewrite/manifest.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"schema_version": "mc-gateway.plugin/v1",
|
||||
"id": "upstream-rewrite",
|
||||
"name": "Upstream Rewrite",
|
||||
"version": "0.1.0",
|
||||
"description": "Rewrite selected upstream targets before dialing.",
|
||||
"artifact_type": "binary",
|
||||
"runtime": {
|
||||
"type": "go-plugin",
|
||||
"entry": "plugin.so",
|
||||
"entry_symbol": "Plugin",
|
||||
"metadata_symbol": "MCGatewayPluginMetadata"
|
||||
},
|
||||
"api_version": "plugin-api/v1",
|
||||
"sdk_module": "github.com/tursom/mc-gateway/plugin/api",
|
||||
"sdk_module_version": "v0.1.0",
|
||||
"go_version": "go1.24.4",
|
||||
"go_os": "linux",
|
||||
"go_arch": "amd64",
|
||||
"extension_points": [
|
||||
{ "type": "hook", "key": "upstream.connect/v1" }
|
||||
],
|
||||
"capabilities": {
|
||||
"extension_points": ["upstream.connect/v1"],
|
||||
"network": { "outbound": ["tcp:*:*"] },
|
||||
"filesystem": { "read": [], "write": [] },
|
||||
"env": []
|
||||
},
|
||||
"runtime_limits": {
|
||||
"handler_timeout_ms": 3000
|
||||
},
|
||||
"config_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"match_host": { "type": "string" },
|
||||
"upstream": { "type": "string" }
|
||||
},
|
||||
"required": ["upstream"]
|
||||
}
|
||||
}
|
||||
@@ -3,21 +3,23 @@ package adminaudit
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
const DefaultListLimit = 200
|
||||
|
||||
type Record struct {
|
||||
ID int64 `json:"id"`
|
||||
Actor string `json:"actor"`
|
||||
SourceIP string `json:"source_ip"`
|
||||
Action string `json:"action"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID string `json:"target_id"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
ID int64 `json:"id"`
|
||||
Actor string `json:"actor"`
|
||||
SourceIP string `json:"source_ip"`
|
||||
Action string `json:"action"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID string `json:"target_id"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
MetadataJSON string `json:"metadata_json"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type Repository struct {
|
||||
@@ -41,13 +43,25 @@ func NewRepositoryWithClock(db *sql.DB, now func() time.Time) Repository {
|
||||
}
|
||||
|
||||
func (r Repository) Record(ctx context.Context, actor, sourceIP, action, targetType, targetID string, success bool, message string) error {
|
||||
return r.RecordWithMetadata(ctx, actor, sourceIP, action, targetType, targetID, success, message, nil)
|
||||
}
|
||||
|
||||
func (r Repository) RecordWithMetadata(ctx context.Context, actor, sourceIP, action, targetType, targetID string, success bool, message string, metadata any) error {
|
||||
if r.db == nil {
|
||||
return nil
|
||||
}
|
||||
metadataJSON := "{}"
|
||||
if metadata != nil {
|
||||
data, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
metadataJSON = string(data)
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO audit_logs(actor, source_ip, action, target_type, target_id, success, message, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
actor, sourceIP, action, targetType, targetID, boolToInt(success), message, r.now().Unix())
|
||||
INSERT INTO audit_logs(actor, source_ip, action, target_type, target_id, success, message, metadata_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
actor, sourceIP, action, targetType, targetID, boolToInt(success), message, metadataJSON, r.now().Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -56,7 +70,7 @@ func (r Repository) List(ctx context.Context, limit int) ([]Record, error) {
|
||||
limit = DefaultListLimit
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, actor, source_ip, action, target_type, target_id, success, message, created_at
|
||||
SELECT id, actor, source_ip, action, target_type, target_id, success, message, metadata_json, created_at
|
||||
FROM audit_logs
|
||||
ORDER BY id DESC
|
||||
LIMIT ?`, limit)
|
||||
@@ -69,7 +83,7 @@ LIMIT ?`, limit)
|
||||
for rows.Next() {
|
||||
var item Record
|
||||
var success int
|
||||
if err := rows.Scan(&item.ID, &item.Actor, &item.SourceIP, &item.Action, &item.TargetType, &item.TargetID, &success, &item.Message, &item.CreatedAt); err != nil {
|
||||
if err := rows.Scan(&item.ID, &item.Actor, &item.SourceIP, &item.Action, &item.TargetType, &item.TargetID, &success, &item.Message, &item.MetadataJSON, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Success = success != 0
|
||||
|
||||
@@ -132,7 +132,7 @@ func validateAdminPaths(adminPath, apiPrefix string) error {
|
||||
return errors.New("admin API prefix cannot equal admin page path")
|
||||
}
|
||||
|
||||
for _, asset := range []string{"app.css", "app.js"} {
|
||||
for _, asset := range []string{"app.css", "config.js", "js"} {
|
||||
assetPath := strings.TrimRight(adminPath, "/") + "/" + asset
|
||||
if apiPrefix == assetPath || strings.HasPrefix(apiPrefix+"/", assetPath+"/") {
|
||||
return errors.New("admin API prefix cannot be under static asset path")
|
||||
|
||||
@@ -60,8 +60,12 @@ func TestParseReturnsErrors(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "api prefix under asset path",
|
||||
env: map[string]string{EnvAPIPrefix: "/admin/app.js/api"},
|
||||
name: "api prefix under js asset path",
|
||||
env: map[string]string{EnvAPIPrefix: "/admin/js/api"},
|
||||
},
|
||||
{
|
||||
name: "api prefix under config asset path",
|
||||
env: map[string]string{EnvAPIPrefix: "/admin/config.js/api"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build (darwin && (amd64 || arm64)) || (freebsd && (amd64 || arm64)) || (linux && (386 || amd64 || arm || arm64 || loong64 || ppc64le || riscv64 || s390x)) || (openbsd && (amd64 || arm64)) || (windows && (386 || amd64 || arm64))
|
||||
|
||||
package admindb
|
||||
|
||||
import (
|
||||
@@ -79,13 +81,119 @@ CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
target_id TEXT NOT NULL,
|
||||
success INTEGER NOT NULL,
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_artifacts (
|
||||
id TEXT PRIMARY KEY,
|
||||
plugin_id TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
sha256 TEXT NOT NULL UNIQUE,
|
||||
package_sha256 TEXT NOT NULL DEFAULT '',
|
||||
size_bytes INTEGER NOT NULL,
|
||||
artifact_type TEXT NOT NULL DEFAULT 'binary',
|
||||
runtime_type TEXT NOT NULL DEFAULT '',
|
||||
runtime_entry TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'uploaded',
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
capabilities_summary_json TEXT NOT NULL DEFAULT '{}',
|
||||
extension_points_json TEXT NOT NULL DEFAULT '[]',
|
||||
api_version TEXT NOT NULL DEFAULT '',
|
||||
go_version TEXT NOT NULL DEFAULT '',
|
||||
go_os TEXT NOT NULL DEFAULT '',
|
||||
go_arch TEXT NOT NULL DEFAULT '',
|
||||
uploaded_by TEXT NOT NULL DEFAULT '',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugins (
|
||||
id TEXT PRIMARY KEY,
|
||||
desired_artifact_id TEXT NOT NULL DEFAULT '',
|
||||
active_artifact_id TEXT NOT NULL DEFAULT '',
|
||||
loaded_artifact_id TEXT NOT NULL DEFAULT '',
|
||||
desired_state TEXT NOT NULL DEFAULT 'disabled',
|
||||
runtime_state TEXT NOT NULL DEFAULT 'not_loaded',
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
desired_generation INTEGER NOT NULL DEFAULT 1,
|
||||
applied_generation INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
runtime_summary_json TEXT NOT NULL DEFAULT '{}',
|
||||
dispatch_summary_json TEXT NOT NULL DEFAULT '{}',
|
||||
deleted_at INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
updated_by TEXT NOT NULL DEFAULT '',
|
||||
FOREIGN KEY (desired_artifact_id) REFERENCES plugin_artifacts(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_operations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plugin_id TEXT NOT NULL DEFAULT '',
|
||||
artifact_id TEXT NOT NULL DEFAULT '',
|
||||
operation TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
actor TEXT NOT NULL DEFAULT '',
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_config_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plugin_id TEXT NOT NULL,
|
||||
artifact_id TEXT NOT NULL DEFAULT '',
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
desired_state TEXT NOT NULL DEFAULT 'disabled',
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
desired_generation INTEGER NOT NULL,
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_routes_enabled ON routes(enabled);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_artifacts_plugin_id ON plugin_artifacts(plugin_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugins_desired_state ON plugins(desired_state, priority);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_operations_plugin_id ON plugin_operations(plugin_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_config_snapshots_plugin_id ON plugin_config_snapshots(plugin_id, created_at);
|
||||
INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (1, strftime('%s','now'));
|
||||
`
|
||||
_, err := db.Exec(schema)
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureColumn(db, "audit_logs", "metadata_json", "TEXT NOT NULL DEFAULT '{}'")
|
||||
}
|
||||
|
||||
func ensureColumn(db *sql.DB, table, column, definition string) error {
|
||||
rows, err := db.Query(`PRAGMA table_info(` + table + `)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, typ string
|
||||
var notNull int
|
||||
var defaultValue any
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬Null, &defaultValue, &pk); err != nil {
|
||||
return err
|
||||
}
|
||||
if name == column {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = db.Exec(`ALTER TABLE ` + table + ` ADD COLUMN ` + column + ` ` + definition)
|
||||
return err
|
||||
}
|
||||
|
||||
17
internal/admindb/db_unsupported.go
Normal file
17
internal/admindb/db_unsupported.go
Normal file
@@ -0,0 +1,17 @@
|
||||
//go:build !((darwin && (amd64 || arm64)) || (freebsd && (amd64 || arm64)) || (linux && (386 || amd64 || arm || arm64 || loong64 || ppc64le || riscv64 || s390x)) || (openbsd && (amd64 || arm64)) || (windows && (386 || amd64 || arm64)))
|
||||
|
||||
package admindb
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func Open(string) (*sql.DB, error) {
|
||||
return nil, fmt.Errorf("sqlite admin database is not supported on %s/%s", runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
|
||||
func Migrate(*sql.DB) error {
|
||||
return fmt.Errorf("sqlite admin database is not supported on %s/%s", runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
@@ -28,6 +28,13 @@ type APIHandlers struct {
|
||||
UserItem SegmentHandlerFunc
|
||||
|
||||
AuditLogs http.HandlerFunc
|
||||
|
||||
PluginArtifacts http.HandlerFunc
|
||||
PluginArtifact SegmentHandlerFunc
|
||||
PluginsList http.HandlerFunc
|
||||
PluginItem SegmentHandlerFunc
|
||||
PluginAction SegmentHandlerFunc
|
||||
PluginDispatch http.HandlerFunc
|
||||
}
|
||||
|
||||
func NewAPIHandler(prefix string, handlers APIHandlers) http.HandlerFunc {
|
||||
@@ -69,6 +76,21 @@ func NewAPIHandler(prefix string, handlers APIHandlers) http.HandlerFunc {
|
||||
callSegmentHandler(w, r, handlers.UserItem, strings.TrimPrefix(path, "/users/"))
|
||||
case path == "/audit-logs" && r.Method == http.MethodGet:
|
||||
callHandler(w, r, handlers.AuditLogs)
|
||||
case path == "/plugin-artifacts" && (r.Method == http.MethodGet || r.Method == http.MethodPost):
|
||||
callHandler(w, r, handlers.PluginArtifacts)
|
||||
case strings.HasPrefix(path, "/plugin-artifacts/"):
|
||||
callSegmentHandler(w, r, handlers.PluginArtifact, strings.TrimPrefix(path, "/plugin-artifacts/"))
|
||||
case path == "/plugins" && r.Method == http.MethodGet:
|
||||
callHandler(w, r, handlers.PluginsList)
|
||||
case path == "/plugins/dispatch-plan" && r.Method == http.MethodGet:
|
||||
callHandler(w, r, handlers.PluginDispatch)
|
||||
case strings.HasPrefix(path, "/plugins/"):
|
||||
pluginPath := strings.TrimPrefix(path, "/plugins/")
|
||||
if strings.Count(pluginPath, "/") == 1 {
|
||||
callSegmentHandler(w, r, handlers.PluginAction, pluginPath)
|
||||
return
|
||||
}
|
||||
callSegmentHandler(w, r, handlers.PluginItem, pluginPath)
|
||||
default:
|
||||
WriteAPIError(w, http.StatusNotFound, "not found")
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@ func TestNewAPIHandlerRoutesRequests(t *testing.T) {
|
||||
{name: "users create", method: http.MethodPost, path: "/admin/api/users", wantCall: "users_create"},
|
||||
{name: "user item", method: http.MethodPatch, path: "/admin/api/users/member", wantCall: "user_item", wantSegment: "member"},
|
||||
{name: "audit logs", method: http.MethodGet, path: "/admin/api/audit-logs", wantCall: "audit_logs"},
|
||||
{name: "plugin artifacts", method: http.MethodGet, path: "/admin/api/plugin-artifacts", wantCall: "plugin_artifacts"},
|
||||
{name: "plugin artifact", method: http.MethodGet, path: "/admin/api/plugin-artifacts/abc", wantCall: "plugin_artifact", wantSegment: "abc"},
|
||||
{name: "plugins list", method: http.MethodGet, path: "/admin/api/plugins", wantCall: "plugins_list"},
|
||||
{name: "plugin item", method: http.MethodPut, path: "/admin/api/plugins/upstream-rewrite", wantCall: "plugin_item", wantSegment: "upstream-rewrite"},
|
||||
{name: "plugin action", method: http.MethodPost, path: "/admin/api/plugins/upstream-rewrite/enable", wantCall: "plugin_action", wantSegment: "upstream-rewrite/enable"},
|
||||
{name: "plugin dispatch", method: http.MethodGet, path: "/admin/api/plugins/dispatch-plan", wantCall: "plugin_dispatch"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -56,6 +62,13 @@ func TestNewAPIHandlerRoutesRequests(t *testing.T) {
|
||||
UserItem: recordSegmentCall(&gotCall, &gotSegment, "user_item"),
|
||||
|
||||
AuditLogs: recordCall(&gotCall, "audit_logs"),
|
||||
|
||||
PluginArtifacts: recordCall(&gotCall, "plugin_artifacts"),
|
||||
PluginArtifact: recordSegmentCall(&gotCall, &gotSegment, "plugin_artifact"),
|
||||
PluginsList: recordCall(&gotCall, "plugins_list"),
|
||||
PluginItem: recordSegmentCall(&gotCall, &gotSegment, "plugin_item"),
|
||||
PluginAction: recordSegmentCall(&gotCall, &gotSegment, "plugin_action"),
|
||||
PluginDispatch: recordCall(&gotCall, "plugin_dispatch"),
|
||||
})
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
package adminhttp
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
staticIndexFile = "admin_static/index.html"
|
||||
staticCSSFile = "admin_static/app.css"
|
||||
staticJSFile = "admin_static/app.js"
|
||||
defaultStaticDir = "cmd/gateway/admin_static"
|
||||
staticConfigFile = "config.js"
|
||||
staticIndexFile = "index.html"
|
||||
)
|
||||
|
||||
type GatewayHandlerOptions struct {
|
||||
AdminPath string
|
||||
AdminAPIPrefix string
|
||||
Assets fs.FS
|
||||
StaticDir string
|
||||
APIHandler http.HandlerFunc
|
||||
WebSocketEnabled bool
|
||||
WebSocketPath string
|
||||
@@ -68,40 +71,67 @@ func serveAdminStatic(w http.ResponseWriter, r *http.Request, opts GatewayHandle
|
||||
}
|
||||
|
||||
if r.URL.Path == opts.AdminPath {
|
||||
serveAdminIndex(w, opts)
|
||||
serveAdminFile(w, r, opts, staticIndexFile)
|
||||
return
|
||||
}
|
||||
|
||||
rel := strings.TrimPrefix(r.URL.Path, opts.AdminPath)
|
||||
switch rel {
|
||||
case "app.css":
|
||||
serveAdminFile(w, r, opts.Assets, staticCSSFile, "text/css; charset=utf-8")
|
||||
case "app.js":
|
||||
serveAdminFile(w, r, opts.Assets, staticJSFile, "application/javascript; charset=utf-8")
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func serveAdminIndex(w http.ResponseWriter, opts GatewayHandlerOptions) {
|
||||
data, err := fs.ReadFile(opts.Assets, staticIndexFile)
|
||||
if err != nil {
|
||||
WriteAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
if rel == staticConfigFile {
|
||||
serveAdminConfig(w, opts)
|
||||
return
|
||||
}
|
||||
html := strings.ReplaceAll(string(data), "__ADMIN_API_PREFIX__", opts.AdminAPIPrefix)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write([]byte(html))
|
||||
serveAdminFile(w, r, opts, rel)
|
||||
}
|
||||
|
||||
func serveAdminFile(w http.ResponseWriter, r *http.Request, assets fs.FS, name, contentType string) {
|
||||
data, err := fs.ReadFile(assets, name)
|
||||
if err != nil {
|
||||
func serveAdminConfig(w http.ResponseWriter, opts GatewayHandlerOptions) {
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write([]byte(`window.MCGatewayAdmin={"apiPrefix":` + strconv.Quote(opts.AdminAPIPrefix) + `};`))
|
||||
}
|
||||
|
||||
func serveAdminFile(w http.ResponseWriter, r *http.Request, opts GatewayHandlerOptions, rel string) {
|
||||
name, ok := cleanStaticPath(rel)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
file := filepath.Join(staticDir(opts), name)
|
||||
info, err := os.Stat(file)
|
||||
if err != nil || info.IsDir() {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(data)
|
||||
http.ServeFile(w, r, file)
|
||||
}
|
||||
|
||||
func cleanStaticPath(rel string) (string, bool) {
|
||||
if rel == "" {
|
||||
return "", false
|
||||
}
|
||||
cleaned := path.Clean("/" + rel)
|
||||
if cleaned == "/" || strings.HasPrefix(cleaned, "/../") {
|
||||
return "", false
|
||||
}
|
||||
name := strings.TrimPrefix(cleaned, "/")
|
||||
if name == staticConfigFile {
|
||||
return "", false
|
||||
}
|
||||
return name, true
|
||||
}
|
||||
|
||||
func staticDir(opts GatewayHandlerOptions) string {
|
||||
if strings.TrimSpace(opts.StaticDir) != "" {
|
||||
return opts.StaticDir
|
||||
}
|
||||
if value := strings.TrimSpace(os.Getenv("MC_GATEWAY_ADMIN_STATIC_DIR")); value != "" {
|
||||
return value
|
||||
}
|
||||
if _, err := os.Stat(defaultStaticDir); err == nil {
|
||||
return defaultStaticDir
|
||||
}
|
||||
if _, err := os.Stat("admin_static"); err == nil {
|
||||
return "admin_static"
|
||||
}
|
||||
return defaultStaticDir
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func TestDecodeJSONRequest(t *testing.T) {
|
||||
@@ -117,16 +118,16 @@ func TestRequestSourceIP(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewGatewayHandlerServesAdminAndAPI(t *testing.T) {
|
||||
assets := fstest.MapFS{
|
||||
staticIndexFile: {Data: []byte(`<html data-api-prefix="__ADMIN_API_PREFIX__"></html>`)},
|
||||
staticCSSFile: {Data: []byte(`body{color:red}`)},
|
||||
staticJSFile: {Data: []byte(`console.log("admin")`)},
|
||||
}
|
||||
staticDir := writeTestAdminStatic(t, map[string]string{
|
||||
"index.html": "<html><script src=\"config.js\"></script></html>",
|
||||
"app.css": "body{color:red}",
|
||||
"js/main.js": `console.log("admin")`,
|
||||
})
|
||||
apiCalled := false
|
||||
handler := NewGatewayHandler(GatewayHandlerOptions{
|
||||
AdminPath: "/ops/",
|
||||
AdminAPIPrefix: "/ops/api",
|
||||
Assets: assets,
|
||||
StaticDir: staticDir,
|
||||
APIHandler: func(w http.ResponseWriter, r *http.Request) {
|
||||
apiCalled = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -149,11 +150,8 @@ func TestNewGatewayHandlerServesAdminAndAPI(t *testing.T) {
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("admin page status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), `data-api-prefix="/ops/api"`) {
|
||||
t.Fatalf("admin page = %q, want API prefix", resp.Body.String())
|
||||
}
|
||||
if got := resp.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Fatalf("admin page Cache-Control = %q, want no-store", got)
|
||||
if !strings.Contains(resp.Body.String(), `script src="config.js"`) {
|
||||
t.Fatalf("admin page = %q, want static index", resp.Body.String())
|
||||
}
|
||||
|
||||
resp = httptest.NewRecorder()
|
||||
@@ -163,6 +161,33 @@ func TestNewGatewayHandlerServesAdminAndAPI(t *testing.T) {
|
||||
t.Fatalf("css response status=%d body=%q", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
resp = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/ops/js/main.js", nil)
|
||||
handler.ServeHTTP(resp, req)
|
||||
if resp.Code != http.StatusOK || !strings.Contains(resp.Body.String(), `console.log("admin")`) {
|
||||
t.Fatalf("js response status=%d body=%q", resp.Code, resp.Body.String())
|
||||
}
|
||||
if got := resp.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Fatalf("js Cache-Control = %q, want no-store", got)
|
||||
}
|
||||
|
||||
resp = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/ops/js/", nil)
|
||||
handler.ServeHTTP(resp, req)
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("js directory status=%d, want %d", resp.Code, http.StatusNotFound)
|
||||
}
|
||||
|
||||
resp = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/ops/config.js", nil)
|
||||
handler.ServeHTTP(resp, req)
|
||||
if resp.Code != http.StatusOK || strings.TrimSpace(resp.Body.String()) != `window.MCGatewayAdmin={"apiPrefix":"/ops/api"};` {
|
||||
t.Fatalf("config response status=%d body=%q", resp.Code, resp.Body.String())
|
||||
}
|
||||
if got := resp.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Fatalf("config Cache-Control = %q, want no-store", got)
|
||||
}
|
||||
|
||||
resp = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/ops/", nil)
|
||||
handler.ServeHTTP(resp, req)
|
||||
@@ -182,13 +207,11 @@ func TestNewGatewayHandlerServesAdminAndAPI(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewGatewayHandlerRegistersWebSocketWhenPathDoesNotConflict(t *testing.T) {
|
||||
assets := fstest.MapFS{
|
||||
staticIndexFile: {Data: []byte(``)},
|
||||
}
|
||||
staticDir := writeTestAdminStatic(t, map[string]string{"index.html": ""})
|
||||
handler := NewGatewayHandler(GatewayHandlerOptions{
|
||||
AdminPath: "/admin/",
|
||||
AdminAPIPrefix: "/admin/api",
|
||||
Assets: assets,
|
||||
StaticDir: staticDir,
|
||||
APIHandler: func(w http.ResponseWriter, r *http.Request) {},
|
||||
WebSocketEnabled: true,
|
||||
WebSocketPath: "/ws",
|
||||
@@ -205,6 +228,21 @@ func TestNewGatewayHandlerRegistersWebSocketWhenPathDoesNotConflict(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestAdminStatic(t *testing.T, files map[string]string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for name, data := range files {
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(%q) error = %v", filepath.Dir(path), err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(%q) error = %v", path, err)
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestWebSocketPathConflictsWithAdmin(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -34,3 +34,11 @@ type PatchUserRequest struct {
|
||||
Password *string `json:"password"`
|
||||
Disabled *bool `json:"disabled"`
|
||||
}
|
||||
|
||||
type PluginDesiredRequest struct {
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
DesiredState string `json:"desired_state"`
|
||||
Priority int `json:"priority"`
|
||||
Config map[string]any `json:"config"`
|
||||
ConfigJSON string `json:"config_json"`
|
||||
}
|
||||
|
||||
311
internal/pluginmanager/artifact.go
Normal file
311
internal/pluginmanager/artifact.go
Normal file
@@ -0,0 +1,311 @@
|
||||
package pluginmanager
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var pluginIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,127}$`)
|
||||
|
||||
type ArtifactStore struct {
|
||||
Root string
|
||||
MaxPackageBytes int64
|
||||
MaxManifestBytes int64
|
||||
MaxEntries int
|
||||
MaxExtractedBytes int64
|
||||
MaxNonRuntimeBytes int64
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type ArtifactUpload struct {
|
||||
SourcePath string
|
||||
FileName string
|
||||
Actor string
|
||||
}
|
||||
|
||||
func NewArtifactStore(root string) ArtifactStore {
|
||||
return ArtifactStore{
|
||||
Root: root,
|
||||
MaxPackageBytes: DefaultPackageMaxBytes,
|
||||
MaxManifestBytes: DefaultManifestMaxBytes,
|
||||
MaxEntries: DefaultPackageMaxEntries,
|
||||
MaxExtractedBytes: DefaultExtractedMaxBytes,
|
||||
MaxNonRuntimeBytes: DefaultNonRuntimeMaxBytes,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
func (s ArtifactStore) ValidateAndStore(upload ArtifactUpload) (ArtifactRecord, error) {
|
||||
if s.Root == "" {
|
||||
return ArtifactRecord{}, errors.New("plugin artifact root is empty")
|
||||
}
|
||||
if s.MaxPackageBytes <= 0 {
|
||||
s.MaxPackageBytes = DefaultPackageMaxBytes
|
||||
}
|
||||
if s.MaxManifestBytes <= 0 {
|
||||
s.MaxManifestBytes = DefaultManifestMaxBytes
|
||||
}
|
||||
if s.MaxEntries <= 0 {
|
||||
s.MaxEntries = DefaultPackageMaxEntries
|
||||
}
|
||||
if s.MaxExtractedBytes <= 0 {
|
||||
s.MaxExtractedBytes = DefaultExtractedMaxBytes
|
||||
}
|
||||
if s.MaxNonRuntimeBytes <= 0 {
|
||||
s.MaxNonRuntimeBytes = DefaultNonRuntimeMaxBytes
|
||||
}
|
||||
if s.now == nil {
|
||||
s.now = time.Now
|
||||
}
|
||||
|
||||
info, err := os.Stat(upload.SourcePath)
|
||||
if err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
if info.Size() <= 0 {
|
||||
return ArtifactRecord{}, errors.New("plugin package is empty")
|
||||
}
|
||||
if info.Size() > s.MaxPackageBytes {
|
||||
return ArtifactRecord{}, fmt.Errorf("plugin package size %d exceeds limit %d", info.Size(), s.MaxPackageBytes)
|
||||
}
|
||||
|
||||
packageSHA, err := fileSHA256(upload.SourcePath)
|
||||
if err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
|
||||
reader, err := zip.OpenReader(upload.SourcePath)
|
||||
if err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
defer reader.Close()
|
||||
if len(reader.File) > s.MaxEntries {
|
||||
return ArtifactRecord{}, fmt.Errorf("plugin package has %d entries, exceeds limit %d", len(reader.File), s.MaxEntries)
|
||||
}
|
||||
|
||||
var manifestFile *zip.File
|
||||
entries := make(map[string]*zip.File)
|
||||
var extractedSize uint64
|
||||
for _, file := range reader.File {
|
||||
clean, err := cleanZipName(file.Name)
|
||||
if err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
if file.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
mode := file.FileInfo().Mode()
|
||||
if !mode.IsRegular() || mode&os.ModeType != 0 {
|
||||
return ArtifactRecord{}, fmt.Errorf("unsupported zip entry type %q", file.Name)
|
||||
}
|
||||
if _, exists := entries[clean]; exists {
|
||||
return ArtifactRecord{}, fmt.Errorf("duplicate zip entry %q", clean)
|
||||
}
|
||||
extractedSize += file.UncompressedSize64
|
||||
if extractedSize > uint64(s.MaxExtractedBytes) {
|
||||
return ArtifactRecord{}, fmt.Errorf("plugin package extracted size exceeds limit %d", s.MaxExtractedBytes)
|
||||
}
|
||||
entries[clean] = file
|
||||
if clean == "manifest.json" {
|
||||
manifestFile = file
|
||||
}
|
||||
}
|
||||
if manifestFile == nil {
|
||||
return ArtifactRecord{}, errors.New("manifest.json is required")
|
||||
}
|
||||
if manifestFile.UncompressedSize64 > uint64(s.MaxManifestBytes) {
|
||||
return ArtifactRecord{}, fmt.Errorf("manifest.json size %d exceeds limit %d", manifestFile.UncompressedSize64, s.MaxManifestBytes)
|
||||
}
|
||||
|
||||
manifestBytes, err := readZipFile(manifestFile, s.MaxManifestBytes)
|
||||
if err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
var manifest Manifest
|
||||
if err := json.Unmarshal(manifestBytes, &manifest); err != nil {
|
||||
return ArtifactRecord{}, fmt.Errorf("invalid manifest.json: %w", err)
|
||||
}
|
||||
if err := validateManifest(manifest); err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
|
||||
entry := manifest.Runtime.Entry
|
||||
pluginFile, ok := entries[entry]
|
||||
if !ok {
|
||||
return ArtifactRecord{}, fmt.Errorf("runtime entry %q is required", entry)
|
||||
}
|
||||
if pluginFile.UncompressedSize64 == 0 {
|
||||
return ArtifactRecord{}, errors.New("runtime entry is empty")
|
||||
}
|
||||
if pluginFile.UncompressedSize64 > uint64(s.MaxPackageBytes) {
|
||||
return ArtifactRecord{}, fmt.Errorf("runtime entry size %d exceeds limit %d", pluginFile.UncompressedSize64, s.MaxPackageBytes)
|
||||
}
|
||||
for name, file := range entries {
|
||||
if name == "manifest.json" || name == entry {
|
||||
continue
|
||||
}
|
||||
if file.UncompressedSize64 > uint64(s.MaxNonRuntimeBytes) {
|
||||
return ArtifactRecord{}, fmt.Errorf("zip entry %q size %d exceeds limit %d", name, file.UncompressedSize64, s.MaxNonRuntimeBytes)
|
||||
}
|
||||
}
|
||||
|
||||
pluginBytes, err := readZipFile(pluginFile, s.MaxPackageBytes)
|
||||
if err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
pluginSum := sha256.Sum256(pluginBytes)
|
||||
artifactID := hex.EncodeToString(pluginSum[:])
|
||||
artifactDir := filepath.Join(s.Root, manifest.ID, artifactID)
|
||||
if err := os.MkdirAll(artifactDir, 0755); err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
pluginPath := filepath.Join(artifactDir, RuntimeEntry)
|
||||
if err := os.WriteFile(pluginPath, pluginBytes, 0644); err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(artifactDir, "manifest.json"), manifestBytes, 0644); err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
|
||||
metadataJSON, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
extensionPoints, err := json.Marshal(extensionPointKeys(manifest))
|
||||
if err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
capabilities := manifest.Capabilities
|
||||
if len(capabilities) == 0 {
|
||||
capabilities = json.RawMessage(`{}`)
|
||||
}
|
||||
now := s.now().Unix()
|
||||
return ArtifactRecord{
|
||||
ID: artifactID,
|
||||
PluginID: manifest.ID,
|
||||
Version: manifest.Version,
|
||||
FileName: upload.FileName,
|
||||
FilePath: pluginPath,
|
||||
SHA256: artifactID,
|
||||
PackageSHA256: packageSHA,
|
||||
SizeBytes: int64(len(pluginBytes)),
|
||||
ArtifactType: manifest.ArtifactType,
|
||||
RuntimeType: manifest.Runtime.Type,
|
||||
RuntimeEntry: manifest.Runtime.Entry,
|
||||
Status: ArtifactStatusLoadable,
|
||||
MetadataJSON: string(metadataJSON),
|
||||
CapabilitiesSummaryJSON: string(capabilities),
|
||||
ExtensionPointsJSON: string(extensionPoints),
|
||||
APIVersion: manifest.APIVersion,
|
||||
GoVersion: manifest.GoVersion,
|
||||
GOOS: manifest.GOOS,
|
||||
GOARCH: manifest.GOARCH,
|
||||
UploadedBy: upload.Actor,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateManifest(manifest Manifest) error {
|
||||
switch {
|
||||
case manifest.SchemaVersion != SchemaVersion:
|
||||
return fmt.Errorf("unsupported schema_version %q", manifest.SchemaVersion)
|
||||
case !pluginIDPattern.MatchString(manifest.ID):
|
||||
return fmt.Errorf("invalid plugin id %q", manifest.ID)
|
||||
case strings.TrimSpace(manifest.Version) == "":
|
||||
return errors.New("version is required")
|
||||
case manifest.ArtifactType != ArtifactTypeBinary:
|
||||
return fmt.Errorf("unsupported artifact_type %q", manifest.ArtifactType)
|
||||
case manifest.Runtime.Type != RuntimeGoPlugin:
|
||||
return fmt.Errorf("unsupported runtime.type %q", manifest.Runtime.Type)
|
||||
case manifest.Runtime.Entry != RuntimeEntry:
|
||||
return fmt.Errorf("unsupported runtime.entry %q", manifest.Runtime.Entry)
|
||||
case manifest.APIVersion != APIVersion:
|
||||
return fmt.Errorf("unsupported api_version %q", manifest.APIVersion)
|
||||
case manifest.GoVersion == "":
|
||||
return errors.New("go_version is required")
|
||||
case manifest.GOOS == "":
|
||||
return errors.New("go_os is required")
|
||||
case manifest.GOARCH == "":
|
||||
return errors.New("go_arch is required")
|
||||
}
|
||||
if manifest.GOOS != runtime.GOOS {
|
||||
return fmt.Errorf("go_os %q does not match gateway %q", manifest.GOOS, runtime.GOOS)
|
||||
}
|
||||
if manifest.GOARCH != runtime.GOARCH {
|
||||
return fmt.Errorf("go_arch %q does not match gateway %q", manifest.GOARCH, runtime.GOARCH)
|
||||
}
|
||||
found := false
|
||||
for _, ep := range manifest.ExtensionPoints {
|
||||
if ep.Type == "hook" && ep.Key == ExtensionUpstreamConnect {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("extension point %q is required", ExtensionUpstreamConnect)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extensionPointKeys(manifest Manifest) []string {
|
||||
keys := make([]string, 0, len(manifest.ExtensionPoints))
|
||||
for _, ep := range manifest.ExtensionPoints {
|
||||
keys = append(keys, ep.Key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func cleanZipName(name string) (string, error) {
|
||||
if name == "" || strings.Contains(name, `\`) || strings.HasPrefix(name, "/") {
|
||||
return "", fmt.Errorf("unsafe zip entry %q", name)
|
||||
}
|
||||
clean := path.Clean(name)
|
||||
if clean == "." || clean != name || strings.HasPrefix(clean, "../") || clean == ".." || path.IsAbs(clean) {
|
||||
return "", fmt.Errorf("unsafe zip entry %q", name)
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
func readZipFile(file *zip.File, maxBytes int64) ([]byte, error) {
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
var buf bytes.Buffer
|
||||
if _, err := io.CopyN(&buf, rc, maxBytes+1); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, err
|
||||
}
|
||||
if int64(buf.Len()) > maxBytes {
|
||||
return nil, fmt.Errorf("zip entry %q exceeds limit %d", file.Name, maxBytes)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func fileSHA256(path string) (string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(hash.Sum(nil)), nil
|
||||
}
|
||||
149
internal/pluginmanager/artifact_test.go
Normal file
149
internal/pluginmanager/artifact_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package pluginmanager
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestArtifactStoreValidateAndStore(t *testing.T) {
|
||||
packagePath := writeTestMCGP(t, map[string][]byte{
|
||||
"manifest.json": testManifestBytes(t, "test-plugin"),
|
||||
"plugin.so": []byte("fake plugin bytes"),
|
||||
})
|
||||
store := NewArtifactStore(t.TempDir())
|
||||
artifact, err := store.ValidateAndStore(ArtifactUpload{
|
||||
SourcePath: packagePath,
|
||||
FileName: "test-plugin.mcgp",
|
||||
Actor: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateAndStore() error = %v", err)
|
||||
}
|
||||
if artifact.PluginID != "test-plugin" || artifact.Status != ArtifactStatusLoadable {
|
||||
t.Fatalf("artifact = %+v, want loadable test-plugin", artifact)
|
||||
}
|
||||
if artifact.SHA256 == "" || artifact.PackageSHA256 == "" {
|
||||
t.Fatalf("artifact hashes not set: %+v", artifact)
|
||||
}
|
||||
if _, err := os.Stat(artifact.FilePath); err != nil {
|
||||
t.Fatalf("stored runtime entry stat error = %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(artifact.FilePath, filepath.Join("test-plugin", artifact.ID, "plugin.so")) {
|
||||
t.Fatalf("artifact file path = %q", artifact.FilePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactStoreRejectsUnsafePackage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
entries map[string][]byte
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "zip slip",
|
||||
entries: map[string][]byte{
|
||||
"manifest.json": testManifestBytes(t, "test-plugin"),
|
||||
"../plugin.so": []byte("fake"),
|
||||
},
|
||||
want: "unsafe zip entry",
|
||||
},
|
||||
{
|
||||
name: "normalized escape",
|
||||
entries: map[string][]byte{
|
||||
"manifest.json": testManifestBytes(t, "test-plugin"),
|
||||
"nested/../plugin.so": []byte("fake"),
|
||||
},
|
||||
want: "unsafe zip entry",
|
||||
},
|
||||
{
|
||||
name: "missing manifest",
|
||||
entries: map[string][]byte{
|
||||
"plugin.so": []byte("fake"),
|
||||
},
|
||||
want: "manifest.json is required",
|
||||
},
|
||||
{
|
||||
name: "missing runtime",
|
||||
entries: map[string][]byte{
|
||||
"manifest.json": testManifestBytes(t, "test-plugin"),
|
||||
},
|
||||
want: `runtime entry "plugin.so" is required`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
store := NewArtifactStore(t.TempDir())
|
||||
_, err := store.ValidateAndStore(ArtifactUpload{
|
||||
SourcePath: writeTestMCGP(t, tt.entries),
|
||||
FileName: "bad.mcgp",
|
||||
Actor: "admin",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("ValidateAndStore() error = %v, want containing %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testManifestBytes(t *testing.T, pluginID string) []byte {
|
||||
t.Helper()
|
||||
manifest := Manifest{
|
||||
SchemaVersion: SchemaVersion,
|
||||
ID: pluginID,
|
||||
Name: "Test Plugin",
|
||||
Version: "0.1.0",
|
||||
ArtifactType: ArtifactTypeBinary,
|
||||
Runtime: RuntimeManifest{
|
||||
Type: RuntimeGoPlugin,
|
||||
Entry: RuntimeEntry,
|
||||
EntrySymbol: "Plugin",
|
||||
},
|
||||
APIVersion: APIVersion,
|
||||
GoVersion: runtime.Version(),
|
||||
GOOS: runtime.GOOS,
|
||||
GOARCH: runtime.GOARCH,
|
||||
ExtensionPoints: []ExtensionPoint{{
|
||||
Type: "hook",
|
||||
Key: ExtensionUpstreamConnect,
|
||||
}},
|
||||
Capabilities: json.RawMessage(`{"extension_points":["upstream.connect/v1"]}`),
|
||||
ConfigSchema: json.RawMessage(`{"type":"object"}`),
|
||||
RuntimeLimits: RuntimeLimits{HandlerTimeoutMS: 3000},
|
||||
}
|
||||
data, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal manifest error = %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func writeTestMCGP(t *testing.T, entries map[string][]byte) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "plugin.mcgp")
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Create package error = %v", err)
|
||||
}
|
||||
zipWriter := zip.NewWriter(file)
|
||||
for name, data := range entries {
|
||||
writer, err := zipWriter.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("Create zip entry error = %v", err)
|
||||
}
|
||||
if _, err := writer.Write(data); err != nil {
|
||||
t.Fatalf("Write zip entry error = %v", err)
|
||||
}
|
||||
}
|
||||
if err := zipWriter.Close(); err != nil {
|
||||
t.Fatalf("Close zip error = %v", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatalf("Close package error = %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
555
internal/pluginmanager/manager.go
Normal file
555
internal/pluginmanager/manager.go
Normal file
@@ -0,0 +1,555 @@
|
||||
package pluginmanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
stdplugin "plugin"
|
||||
"reflect"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/tursom/mc-gateway/plugin/api"
|
||||
)
|
||||
|
||||
type RuntimeAdapter interface {
|
||||
Load(ctx context.Context, artifact ArtifactRecord, pluginRecord PluginRecord, gateway *Gateway) (api.Plugin, error)
|
||||
}
|
||||
|
||||
type GoPluginAdapter struct{}
|
||||
|
||||
func (a GoPluginAdapter) Load(ctx context.Context, artifact ArtifactRecord, pluginRecord PluginRecord, gateway *Gateway) (api.Plugin, error) {
|
||||
_ = ctx
|
||||
opened, err := stdplugin.Open(artifact.FilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
symbolName := "Plugin"
|
||||
var manifest Manifest
|
||||
if err := json.Unmarshal([]byte(artifact.MetadataJSON), &manifest); err == nil && manifest.Runtime.EntrySymbol != "" {
|
||||
symbolName = manifest.Runtime.EntrySymbol
|
||||
}
|
||||
symbol, err := opened.Lookup(symbolName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
factory, ok := symbol.(func() api.Plugin)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("plugin symbol %q has invalid signature", symbolName)
|
||||
}
|
||||
instance := factory()
|
||||
cfg := instance.NewConfigObj()
|
||||
if cfg != nil && pluginRecord.ConfigJSON != "" && canUnmarshalInto(cfg) {
|
||||
if err := json.Unmarshal([]byte(pluginRecord.ConfigJSON), cfg); err != nil {
|
||||
return nil, fmt.Errorf("decode plugin config: %w", err)
|
||||
}
|
||||
}
|
||||
if err := instance.ReloadConfig(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := instance.Init(gateway); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
func canUnmarshalInto(value any) bool {
|
||||
if value == nil {
|
||||
return false
|
||||
}
|
||||
kind := reflect.TypeOf(value).Kind()
|
||||
return kind == reflect.Pointer || kind == reflect.Map || kind == reflect.Slice
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
repo Repository
|
||||
store ArtifactStore
|
||||
adapter RuntimeAdapter
|
||||
handleConn func(net.Conn)
|
||||
wg *sync.WaitGroup
|
||||
|
||||
mu sync.Mutex
|
||||
loaded map[string]*loadedPlugin
|
||||
snapshot atomic.Value
|
||||
}
|
||||
|
||||
type loadedPlugin struct {
|
||||
record PluginRecord
|
||||
artifact ArtifactRecord
|
||||
instance api.Plugin
|
||||
gateway *Gateway
|
||||
handlers []*upstreamHandler
|
||||
}
|
||||
|
||||
type upstreamHandler struct {
|
||||
pluginID string
|
||||
artifactID string
|
||||
priority int
|
||||
handlerID string
|
||||
timeout time.Duration
|
||||
accept func(api.UpstreamConnectRequest) bool
|
||||
handle func(api.UpstreamConnectRequest) (net.Conn, error)
|
||||
|
||||
calls atomic.Uint64
|
||||
errors atomic.Uint64
|
||||
panics atomic.Uint64
|
||||
timeouts atomic.Uint64
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
DB *sql.DB
|
||||
ArtifactRoot string
|
||||
HandleConn func(net.Conn)
|
||||
WaitGroup *sync.WaitGroup
|
||||
Adapter RuntimeAdapter
|
||||
}
|
||||
|
||||
func New(options Options) *Manager {
|
||||
adapter := options.Adapter
|
||||
if adapter == nil {
|
||||
adapter = GoPluginAdapter{}
|
||||
}
|
||||
manager := &Manager{
|
||||
repo: NewRepository(options.DB),
|
||||
store: NewArtifactStore(options.ArtifactRoot),
|
||||
adapter: adapter,
|
||||
handleConn: options.HandleConn,
|
||||
wg: options.WaitGroup,
|
||||
loaded: make(map[string]*loadedPlugin),
|
||||
}
|
||||
manager.publish(nil)
|
||||
return manager
|
||||
}
|
||||
|
||||
func (m *Manager) UploadArtifact(ctx context.Context, upload ArtifactUpload) (ArtifactRecord, error) {
|
||||
artifact, err := m.store.ValidateAndStore(upload)
|
||||
if err != nil {
|
||||
_ = m.repo.RecordOperation(ctx, "", "", "artifact_upload", "failed", upload.Actor, err.Error(), nil)
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
if err := m.repo.SaveArtifact(ctx, artifact); err != nil {
|
||||
return ArtifactRecord{}, err
|
||||
}
|
||||
_ = m.repo.RecordOperation(ctx, artifact.PluginID, artifact.ID, "artifact_upload", "succeeded", upload.Actor, "artifact uploaded", map[string]any{
|
||||
"sha256": artifact.SHA256,
|
||||
"package_sha256": artifact.PackageSHA256,
|
||||
"api_version": artifact.APIVersion,
|
||||
"extension_points": artifact.ExtensionPointsJSON,
|
||||
})
|
||||
return artifact, nil
|
||||
}
|
||||
|
||||
func (m *Manager) SetDesired(ctx context.Context, actor, pluginID, artifactID, desiredState, configJSON string, priority int) (PluginRecord, error) {
|
||||
pluginRecord, err := m.repo.UpsertDesired(ctx, actor, pluginID, artifactID, desiredState, configJSON, priority)
|
||||
if err != nil {
|
||||
_ = m.repo.RecordOperation(ctx, pluginID, artifactID, "desired_update", "failed", actor, err.Error(), nil)
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
_ = m.repo.RecordOperation(ctx, pluginID, artifactID, "desired_update", "succeeded", actor, "desired state updated", map[string]any{
|
||||
"desired_state": desiredState,
|
||||
"desired_generation": pluginRecord.DesiredGeneration,
|
||||
"priority": pluginRecord.Priority,
|
||||
})
|
||||
return pluginRecord, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Load(ctx context.Context, actor, pluginID string) (PluginRecord, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
pluginRecord, err := m.repo.Plugin(ctx, pluginID)
|
||||
if err != nil {
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
loaded, err := m.loadLocked(ctx, pluginRecord)
|
||||
if err != nil {
|
||||
_ = m.repo.RecordOperation(ctx, pluginID, pluginRecord.DesiredArtifactID, "load", "failed", actor, err.Error(), nil)
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
_ = m.repo.RecordOperation(ctx, pluginID, loaded.artifact.ID, "load", "succeeded", actor, "plugin loaded", nil)
|
||||
return m.repo.Plugin(ctx, pluginID)
|
||||
}
|
||||
|
||||
func (m *Manager) Enable(ctx context.Context, actor, pluginID string) (PluginRecord, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
pluginRecord, err := m.repo.Plugin(ctx, pluginID)
|
||||
if err != nil {
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
if pluginRecord.DesiredState != DesiredEnabled {
|
||||
pluginRecord, err = m.repo.UpsertDesired(ctx, actor, pluginRecord.ID, pluginRecord.DesiredArtifactID, DesiredEnabled, pluginRecord.ConfigJSON, pluginRecord.Priority)
|
||||
if err != nil {
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
}
|
||||
loaded, err := m.loadLocked(ctx, pluginRecord)
|
||||
if err != nil {
|
||||
_ = m.repo.RecordOperation(ctx, pluginID, pluginRecord.DesiredArtifactID, "enable", "failed", actor, err.Error(), nil)
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
if len(loaded.handlers) == 0 {
|
||||
err := fmt.Errorf("plugin %q did not register %s", pluginID, ExtensionUpstreamConnect)
|
||||
_ = m.repo.MarkRuntime(ctx, pluginID, RuntimeFailed, "", loaded.artifact.ID, pluginRecord.AppliedGeneration, err.Error(), map[string]any{"error": err.Error()}, nil)
|
||||
_ = m.repo.RecordOperation(ctx, pluginID, pluginRecord.DesiredArtifactID, "enable", "failed", actor, err.Error(), nil)
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
|
||||
current := m.currentHandlersLocked()
|
||||
current[pluginID] = loaded.handlers
|
||||
next := flattenHandlers(current)
|
||||
if err := m.markEnabled(ctx, loaded); err != nil {
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
m.publish(next)
|
||||
_ = m.repo.UpdateArtifactStatus(ctx, loaded.artifact.ID, ArtifactStatusLoaded, "")
|
||||
_ = m.repo.RecordOperation(ctx, pluginID, loaded.artifact.ID, "enable", "succeeded", actor, "plugin enabled", map[string]any{
|
||||
"desired_generation": loaded.record.DesiredGeneration,
|
||||
"handler_count": len(loaded.handlers),
|
||||
})
|
||||
return m.repo.Plugin(ctx, pluginID)
|
||||
}
|
||||
|
||||
func (m *Manager) Disable(ctx context.Context, actor, pluginID string) (PluginRecord, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
pluginRecord, err := m.repo.Plugin(ctx, pluginID)
|
||||
if err != nil {
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
pluginRecord, err = m.repo.UpsertDesired(ctx, actor, pluginRecord.ID, pluginRecord.DesiredArtifactID, DesiredDisabled, pluginRecord.ConfigJSON, pluginRecord.Priority)
|
||||
if err != nil {
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
m.removeFromDispatchLocked(pluginID)
|
||||
if loaded := m.loaded[pluginID]; loaded != nil && loaded.instance != nil {
|
||||
if err := loaded.instance.Destroy(); err != nil {
|
||||
_ = m.repo.RecordOperation(ctx, pluginID, loaded.artifact.ID, "disable", "warning", actor, err.Error(), nil)
|
||||
}
|
||||
}
|
||||
delete(m.loaded, pluginID)
|
||||
if err := m.repo.MarkRuntime(ctx, pluginID, RuntimeDisabled, "", "", pluginRecord.DesiredGeneration, "", nil, nil); err != nil {
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
_ = m.repo.RecordOperation(ctx, pluginID, pluginRecord.DesiredArtifactID, "disable", "succeeded", actor, "plugin disabled", nil)
|
||||
return m.repo.Plugin(ctx, pluginID)
|
||||
}
|
||||
|
||||
func (m *Manager) Delete(ctx context.Context, actor, pluginID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
pluginRecord, err := m.repo.Plugin(ctx, pluginID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.removeFromDispatchLocked(pluginID)
|
||||
if loaded := m.loaded[pluginID]; loaded != nil && loaded.instance != nil {
|
||||
_ = loaded.instance.Destroy()
|
||||
}
|
||||
delete(m.loaded, pluginID)
|
||||
if _, err := m.repo.UpsertDesired(ctx, actor, pluginRecord.ID, pluginRecord.DesiredArtifactID, DesiredDeleted, pluginRecord.ConfigJSON, pluginRecord.Priority); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = m.repo.RecordOperation(ctx, pluginID, pluginRecord.DesiredArtifactID, "delete", "succeeded", actor, "plugin deleted", map[string]any{
|
||||
"cleanup": "pending_restart_for_loaded_go_plugin",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Reconcile(ctx context.Context) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
desired, err := m.repo.DesiredEnabled(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nextByPlugin := make(map[string][]*upstreamHandler)
|
||||
for _, pluginRecord := range desired {
|
||||
loaded, err := m.loadLocked(ctx, pluginRecord)
|
||||
if err != nil {
|
||||
_ = m.repo.MarkRuntime(ctx, pluginRecord.ID, RuntimeFailed, "", "", pluginRecord.AppliedGeneration, err.Error(), map[string]any{"error": err.Error()}, nil)
|
||||
_ = m.repo.RecordOperation(ctx, pluginRecord.ID, pluginRecord.DesiredArtifactID, "reconcile", "failed", "system", err.Error(), nil)
|
||||
continue
|
||||
}
|
||||
if len(loaded.handlers) == 0 {
|
||||
err := fmt.Errorf("plugin %q did not register %s", pluginRecord.ID, ExtensionUpstreamConnect)
|
||||
_ = m.repo.MarkRuntime(ctx, pluginRecord.ID, RuntimeFailed, "", loaded.artifact.ID, pluginRecord.AppliedGeneration, err.Error(), map[string]any{"error": err.Error()}, nil)
|
||||
_ = m.repo.RecordOperation(ctx, pluginRecord.ID, pluginRecord.DesiredArtifactID, "reconcile", "failed", "system", err.Error(), nil)
|
||||
continue
|
||||
}
|
||||
nextByPlugin[pluginRecord.ID] = loaded.handlers
|
||||
_ = m.markEnabled(ctx, loaded)
|
||||
}
|
||||
m.publish(flattenHandlers(nextByPlugin))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) ConnectUpstream(ctx context.Context, req api.UpstreamConnectRequest) (UpstreamResult, error) {
|
||||
value := m.snapshot.Load()
|
||||
if value == nil {
|
||||
return UpstreamResult{}, nil
|
||||
}
|
||||
handlers, ok := value.([]*upstreamHandler)
|
||||
if !ok {
|
||||
return UpstreamResult{}, nil
|
||||
}
|
||||
if req.Context == nil {
|
||||
req.Context = ctx
|
||||
}
|
||||
for _, handler := range handlers {
|
||||
accepted, err := handler.accepts(req)
|
||||
if err != nil {
|
||||
return UpstreamResult{Handled: true}, err
|
||||
}
|
||||
if !accepted {
|
||||
continue
|
||||
}
|
||||
conn, err := handler.invoke(req)
|
||||
if errors.Is(err, api.ErrPass) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return UpstreamResult{Handled: true}, err
|
||||
}
|
||||
if conn != nil {
|
||||
return UpstreamResult{Conn: conn, Handled: true}, nil
|
||||
}
|
||||
}
|
||||
return UpstreamResult{}, nil
|
||||
}
|
||||
|
||||
func (h *upstreamHandler) accepts(req api.UpstreamConnectRequest) (accepted bool, err error) {
|
||||
if h.accept == nil {
|
||||
return true, nil
|
||||
}
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
h.panics.Add(1)
|
||||
accepted = false
|
||||
err = fmt.Errorf("plugin %s acceptor panic: %v", h.pluginID, rec)
|
||||
}
|
||||
}()
|
||||
return h.accept(req), nil
|
||||
}
|
||||
|
||||
func (m *Manager) ListArtifacts(ctx context.Context, pluginID string) ([]ArtifactRecord, error) {
|
||||
return m.repo.ListArtifacts(ctx, pluginID)
|
||||
}
|
||||
|
||||
func (m *Manager) Artifact(ctx context.Context, id string) (ArtifactRecord, error) {
|
||||
return m.repo.Artifact(ctx, id)
|
||||
}
|
||||
|
||||
func (m *Manager) ListPlugins(ctx context.Context) ([]PluginRecord, error) {
|
||||
return m.repo.ListPlugins(ctx)
|
||||
}
|
||||
|
||||
func (m *Manager) Plugin(ctx context.Context, id string) (PluginRecord, error) {
|
||||
return m.repo.Plugin(ctx, id)
|
||||
}
|
||||
|
||||
func (m *Manager) DispatchPlan(ctx context.Context) DispatchPlan {
|
||||
value := m.snapshot.Load()
|
||||
plan := DispatchPlan{UpdatedAt: time.Now().Unix()}
|
||||
if handlers, ok := value.([]*upstreamHandler); ok {
|
||||
plan.Handlers = handlerSummaries(handlers)
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
func (m *Manager) loadLocked(ctx context.Context, pluginRecord PluginRecord) (*loadedPlugin, error) {
|
||||
if loaded := m.loaded[pluginRecord.ID]; loaded != nil &&
|
||||
loaded.artifact.ID == pluginRecord.DesiredArtifactID &&
|
||||
loaded.record.DesiredGeneration == pluginRecord.DesiredGeneration {
|
||||
return loaded, nil
|
||||
}
|
||||
artifact, err := m.repo.Artifact(ctx, pluginRecord.DesiredArtifactID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if artifact.Status == ArtifactStatusDeleted || artifact.Status == ArtifactStatusRejected {
|
||||
return nil, fmt.Errorf("artifact status %q is not loadable", artifact.Status)
|
||||
}
|
||||
|
||||
gateway := NewGateway(pluginRecord.ID, m.handleConn, m.wg)
|
||||
instance, err := m.adapter.Load(ctx, artifact, pluginRecord, gateway)
|
||||
if err != nil {
|
||||
_ = m.repo.MarkRuntime(ctx, pluginRecord.ID, RuntimeFailed, "", "", pluginRecord.AppliedGeneration, err.Error(), map[string]any{"error": err.Error()}, nil)
|
||||
return nil, err
|
||||
}
|
||||
handlers := buildHandlers(pluginRecord, artifact, gateway)
|
||||
loaded := &loadedPlugin{
|
||||
record: pluginRecord,
|
||||
artifact: artifact,
|
||||
instance: instance,
|
||||
gateway: gateway,
|
||||
handlers: handlers,
|
||||
}
|
||||
m.loaded[pluginRecord.ID] = loaded
|
||||
if err := m.repo.MarkRuntime(ctx, pluginRecord.ID, RuntimeLoaded, "", artifact.ID, pluginRecord.AppliedGeneration, "", map[string]any{
|
||||
"handler_count": len(handlers),
|
||||
}, handlerSummaries(handlers)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return loaded, nil
|
||||
}
|
||||
|
||||
func (m *Manager) markEnabled(ctx context.Context, loaded *loadedPlugin) error {
|
||||
return m.repo.MarkRuntime(ctx, loaded.record.ID, RuntimeEnabled, loaded.artifact.ID, loaded.artifact.ID, loaded.record.DesiredGeneration, "", map[string]any{
|
||||
"handler_count": len(loaded.handlers),
|
||||
}, handlerSummaries(loaded.handlers))
|
||||
}
|
||||
|
||||
func (m *Manager) currentHandlersLocked() map[string][]*upstreamHandler {
|
||||
current := make(map[string][]*upstreamHandler)
|
||||
value := m.snapshot.Load()
|
||||
if handlers, ok := value.([]*upstreamHandler); ok {
|
||||
for _, handler := range handlers {
|
||||
current[handler.pluginID] = append(current[handler.pluginID], handler)
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
func (m *Manager) removeFromDispatchLocked(pluginID string) {
|
||||
current := m.currentHandlersLocked()
|
||||
delete(current, pluginID)
|
||||
m.publish(flattenHandlers(current))
|
||||
}
|
||||
|
||||
func (m *Manager) publish(handlers []*upstreamHandler) {
|
||||
sort.SliceStable(handlers, func(i, j int) bool {
|
||||
if handlers[i].priority != handlers[j].priority {
|
||||
return handlers[i].priority < handlers[j].priority
|
||||
}
|
||||
if handlers[i].pluginID != handlers[j].pluginID {
|
||||
return handlers[i].pluginID < handlers[j].pluginID
|
||||
}
|
||||
return handlers[i].handlerID < handlers[j].handlerID
|
||||
})
|
||||
m.snapshot.Store(handlers)
|
||||
}
|
||||
|
||||
func buildHandlers(pluginRecord PluginRecord, artifact ArtifactRecord, gateway *Gateway) []*upstreamHandler {
|
||||
timeout := DefaultHandlerTimeout
|
||||
var manifest Manifest
|
||||
if err := json.Unmarshal([]byte(artifact.MetadataJSON), &manifest); err == nil && manifest.RuntimeLimits.HandlerTimeoutMS > 0 {
|
||||
timeout = time.Duration(manifest.RuntimeLimits.HandlerTimeoutMS) * time.Millisecond
|
||||
}
|
||||
var handlers []*upstreamHandler
|
||||
if hook, ok := gateway.UpstreamConnectHandler(); ok {
|
||||
handlers = append(handlers, &upstreamHandler{
|
||||
pluginID: pluginRecord.ID,
|
||||
artifactID: artifact.ID,
|
||||
priority: pluginRecord.Priority,
|
||||
handlerID: "upstream.connect/v1",
|
||||
timeout: timeout,
|
||||
accept: hook.Acceptor(),
|
||||
handle: hook.Handler(),
|
||||
})
|
||||
}
|
||||
if hook, ok := gateway.LegacyUpstreamHandler(); ok {
|
||||
acceptor := hook.Acceptor()
|
||||
handler := hook.Handler()
|
||||
handlers = append(handlers, &upstreamHandler{
|
||||
pluginID: pluginRecord.ID,
|
||||
artifactID: artifact.ID,
|
||||
priority: pluginRecord.Priority,
|
||||
handlerID: "legacy-upstream",
|
||||
timeout: timeout,
|
||||
accept: func(req api.UpstreamConnectRequest) bool {
|
||||
return acceptor(req.Source, req.Upstream)
|
||||
},
|
||||
handle: func(req api.UpstreamConnectRequest) (net.Conn, error) {
|
||||
return handler(req.Source, req.Upstream)
|
||||
},
|
||||
})
|
||||
}
|
||||
return handlers
|
||||
}
|
||||
|
||||
func (h *upstreamHandler) invoke(req api.UpstreamConnectRequest) (conn net.Conn, err error) {
|
||||
h.calls.Add(1)
|
||||
ctx := req.Context
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if h.timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, h.timeout)
|
||||
defer cancel()
|
||||
}
|
||||
req.Context = ctx
|
||||
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
h.panics.Add(1)
|
||||
done <- result{err: fmt.Errorf("plugin %s panic: %v", h.pluginID, rec)}
|
||||
}
|
||||
}()
|
||||
conn, err := h.handle(req)
|
||||
done <- result{conn: conn, err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
h.timeouts.Add(1)
|
||||
go closeLateConn(done)
|
||||
return nil, ctx.Err()
|
||||
case result := <-done:
|
||||
if result.err != nil && !errors.Is(result.err, api.ErrPass) {
|
||||
h.errors.Add(1)
|
||||
}
|
||||
return result.conn, result.err
|
||||
}
|
||||
}
|
||||
|
||||
type result struct {
|
||||
conn net.Conn
|
||||
err error
|
||||
}
|
||||
|
||||
func closeLateConn(done <-chan result) {
|
||||
result := <-done
|
||||
if result.conn != nil {
|
||||
_ = result.conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func flattenHandlers(byPlugin map[string][]*upstreamHandler) []*upstreamHandler {
|
||||
var handlers []*upstreamHandler
|
||||
for _, pluginHandlers := range byPlugin {
|
||||
handlers = append(handlers, pluginHandlers...)
|
||||
}
|
||||
return handlers
|
||||
}
|
||||
|
||||
func handlerSummaries(handlers []*upstreamHandler) []DispatchHandlerSummary {
|
||||
summaries := make([]DispatchHandlerSummary, 0, len(handlers))
|
||||
for _, handler := range handlers {
|
||||
summaries = append(summaries, DispatchHandlerSummary{
|
||||
PluginID: handler.pluginID,
|
||||
ArtifactID: handler.artifactID,
|
||||
Priority: handler.priority,
|
||||
HandlerID: handler.handlerID,
|
||||
ExtensionPoint: ExtensionUpstreamConnect,
|
||||
TimeoutMS: handler.timeout.Milliseconds(),
|
||||
Calls: handler.calls.Load(),
|
||||
Errors: handler.errors.Load(),
|
||||
Panics: handler.panics.Load(),
|
||||
Timeouts: handler.timeouts.Load(),
|
||||
})
|
||||
}
|
||||
return summaries
|
||||
}
|
||||
323
internal/pluginmanager/manager_test.go
Normal file
323
internal/pluginmanager/manager_test.go
Normal file
@@ -0,0 +1,323 @@
|
||||
package pluginmanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/tursom/mc-gateway/internal/admindb"
|
||||
"github.com/tursom/mc-gateway/plugin/api"
|
||||
)
|
||||
|
||||
func TestManagerUploadDoesNotLoadPlugin(t *testing.T) {
|
||||
adapter := &fakeAdapter{}
|
||||
manager := newManagerForTest(t, adapter)
|
||||
|
||||
artifact := uploadTestArtifact(t, manager, "plugin-a")
|
||||
if artifact.PluginID != "plugin-a" {
|
||||
t.Fatalf("artifact plugin = %q, want plugin-a", artifact.PluginID)
|
||||
}
|
||||
if adapter.loads != 0 {
|
||||
t.Fatalf("adapter loads = %d, want 0 for upload-only validation", adapter.loads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerEnableDisableAndDispatch(t *testing.T) {
|
||||
adapter := &fakeAdapter{}
|
||||
manager := newManagerForTest(t, adapter)
|
||||
artifact := uploadTestArtifact(t, manager, "plugin-a")
|
||||
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "plugin-a", artifact.ID, DesiredDisabled, `{"upstream":"override"}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
if _, err := manager.Enable(context.Background(), "admin", "plugin-a"); err != nil {
|
||||
t.Fatalf("Enable() error = %v", err)
|
||||
}
|
||||
if adapter.loads != 1 {
|
||||
t.Fatalf("adapter loads = %d, want 1", adapter.loads)
|
||||
}
|
||||
result, err := manager.ConnectUpstream(context.Background(), api.UpstreamConnectRequest{
|
||||
Host: "play.example",
|
||||
Upstream: "backend.example:25565",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ConnectUpstream() error = %v", err)
|
||||
}
|
||||
if !result.Handled || result.Conn == nil {
|
||||
t.Fatalf("ConnectUpstream() = %+v, want handled conn", result)
|
||||
}
|
||||
|
||||
plugin, err := manager.Disable(context.Background(), "admin", "plugin-a")
|
||||
if err != nil {
|
||||
t.Fatalf("Disable() error = %v", err)
|
||||
}
|
||||
if plugin.RuntimeState != RuntimeDisabled {
|
||||
t.Fatalf("disabled runtime state = %q, want %q", plugin.RuntimeState, RuntimeDisabled)
|
||||
}
|
||||
result, err = manager.ConnectUpstream(context.Background(), api.UpstreamConnectRequest{
|
||||
Host: "play.example",
|
||||
Upstream: "backend.example:25565",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ConnectUpstream(disabled) error = %v", err)
|
||||
}
|
||||
if result.Handled {
|
||||
t.Fatalf("ConnectUpstream(disabled) = %+v, want pass-through", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerErrPassContinuesToNextHandler(t *testing.T) {
|
||||
adapter := &fakeAdapter{
|
||||
handlers: map[string]api.UpstreamConnectHandler{
|
||||
"plugin-a": func(api.UpstreamConnectRequest) (net.Conn, error) {
|
||||
return nil, api.ErrPass
|
||||
},
|
||||
"plugin-b": func(api.UpstreamConnectRequest) (net.Conn, error) {
|
||||
return newMemoryConn(), nil
|
||||
},
|
||||
},
|
||||
}
|
||||
manager := newManagerForTest(t, adapter)
|
||||
artifactA := uploadTestArtifact(t, manager, "plugin-a")
|
||||
artifactB := uploadTestArtifact(t, manager, "plugin-b")
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "plugin-a", artifactA.ID, DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired(a) error = %v", err)
|
||||
}
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "plugin-b", artifactB.ID, DesiredEnabled, `{}`, 20); err != nil {
|
||||
t.Fatalf("SetDesired(b) error = %v", err)
|
||||
}
|
||||
if err := manager.Reconcile(context.Background()); err != nil {
|
||||
t.Fatalf("Reconcile() error = %v", err)
|
||||
}
|
||||
|
||||
result, err := manager.ConnectUpstream(context.Background(), api.UpstreamConnectRequest{Host: "play.example", Upstream: "backend"})
|
||||
if err != nil {
|
||||
t.Fatalf("ConnectUpstream() error = %v", err)
|
||||
}
|
||||
if !result.Handled || result.Conn == nil {
|
||||
t.Fatalf("ConnectUpstream() = %+v, want second handler conn", result)
|
||||
}
|
||||
plan := manager.DispatchPlan(context.Background())
|
||||
if len(plan.Handlers) != 2 {
|
||||
t.Fatalf("dispatch handlers = %d, want 2", len(plan.Handlers))
|
||||
}
|
||||
if plan.Handlers[0].PluginID != "plugin-a" || plan.Handlers[1].PluginID != "plugin-b" {
|
||||
t.Fatalf("dispatch order = %+v, want plugin-a then plugin-b", plan.Handlers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerPanicDoesNotReplaceExistingDispatch(t *testing.T) {
|
||||
adapter := &fakeAdapter{
|
||||
handlers: map[string]api.UpstreamConnectHandler{
|
||||
"plugin-a": func(api.UpstreamConnectRequest) (net.Conn, error) {
|
||||
return newMemoryConn(), nil
|
||||
},
|
||||
"plugin-b": func(api.UpstreamConnectRequest) (net.Conn, error) {
|
||||
panic("boom")
|
||||
},
|
||||
},
|
||||
}
|
||||
manager := newManagerForTest(t, adapter)
|
||||
artifactA := uploadTestArtifact(t, manager, "plugin-a")
|
||||
artifactB := uploadTestArtifact(t, manager, "plugin-b")
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "plugin-a", artifactA.ID, DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired(a) error = %v", err)
|
||||
}
|
||||
if _, err := manager.Enable(context.Background(), "admin", "plugin-a"); err != nil {
|
||||
t.Fatalf("Enable(a) error = %v", err)
|
||||
}
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "plugin-b", artifactB.ID, DesiredEnabled, `{}`, 5); err != nil {
|
||||
t.Fatalf("SetDesired(b) error = %v", err)
|
||||
}
|
||||
if _, err := manager.Enable(context.Background(), "admin", "plugin-b"); err != nil {
|
||||
t.Fatalf("Enable(b) error = %v", err)
|
||||
}
|
||||
_, err := manager.ConnectUpstream(context.Background(), api.UpstreamConnectRequest{Host: "play.example", Upstream: "backend"})
|
||||
if err == nil {
|
||||
t.Fatal("ConnectUpstream() error = nil, want panic converted to error")
|
||||
}
|
||||
plan := manager.DispatchPlan(context.Background())
|
||||
if len(plan.Handlers) != 2 {
|
||||
t.Fatalf("dispatch handlers = %d, want 2", len(plan.Handlers))
|
||||
}
|
||||
if plan.Handlers[0].PluginID != "plugin-b" || plan.Handlers[0].Panics != 1 {
|
||||
t.Fatalf("first handler summary = %+v, want plugin-b panic count", plan.Handlers[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerLoadFailureKeepsExistingDispatch(t *testing.T) {
|
||||
adapter := &fakeAdapter{
|
||||
loadErrs: map[string]error{
|
||||
"plugin-b": errors.New("open failed"),
|
||||
},
|
||||
}
|
||||
manager := newManagerForTest(t, adapter)
|
||||
artifactA := uploadTestArtifact(t, manager, "plugin-a")
|
||||
artifactB := uploadTestArtifact(t, manager, "plugin-b")
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "plugin-a", artifactA.ID, DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired(a) error = %v", err)
|
||||
}
|
||||
if _, err := manager.Enable(context.Background(), "admin", "plugin-a"); err != nil {
|
||||
t.Fatalf("Enable(a) error = %v", err)
|
||||
}
|
||||
if _, err := manager.SetDesired(context.Background(), "admin", "plugin-b", artifactB.ID, DesiredEnabled, `{}`, 5); err != nil {
|
||||
t.Fatalf("SetDesired(b) error = %v", err)
|
||||
}
|
||||
if _, err := manager.Enable(context.Background(), "admin", "plugin-b"); err == nil {
|
||||
t.Fatal("Enable(b) error = nil, want load failure")
|
||||
}
|
||||
|
||||
plan := manager.DispatchPlan(context.Background())
|
||||
if len(plan.Handlers) != 1 || plan.Handlers[0].PluginID != "plugin-a" {
|
||||
t.Fatalf("dispatch plan after failed enable = %+v, want only plugin-a", plan)
|
||||
}
|
||||
result, err := manager.ConnectUpstream(context.Background(), api.UpstreamConnectRequest{Host: "play.example", Upstream: "backend"})
|
||||
if err != nil {
|
||||
t.Fatalf("ConnectUpstream() error = %v", err)
|
||||
}
|
||||
if !result.Handled || result.Conn == nil {
|
||||
t.Fatalf("ConnectUpstream() = %+v, want existing plugin-a conn", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerReconcileRestoresEnabledPlugin(t *testing.T) {
|
||||
db := openPluginManagerTestDB(t)
|
||||
root := t.TempDir()
|
||||
firstAdapter := &fakeAdapter{}
|
||||
first := New(Options{
|
||||
DB: db,
|
||||
ArtifactRoot: root,
|
||||
Adapter: firstAdapter,
|
||||
})
|
||||
artifact := uploadTestArtifact(t, first, "plugin-a")
|
||||
if _, err := first.SetDesired(context.Background(), "admin", "plugin-a", artifact.ID, DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
if _, err := first.Enable(context.Background(), "admin", "plugin-a"); err != nil {
|
||||
t.Fatalf("Enable() error = %v", err)
|
||||
}
|
||||
|
||||
secondAdapter := &fakeAdapter{}
|
||||
second := New(Options{
|
||||
DB: db,
|
||||
ArtifactRoot: root,
|
||||
Adapter: secondAdapter,
|
||||
})
|
||||
if err := second.Reconcile(context.Background()); err != nil {
|
||||
t.Fatalf("Reconcile() error = %v", err)
|
||||
}
|
||||
if secondAdapter.loads != 1 {
|
||||
t.Fatalf("reconcile loads = %d, want 1", secondAdapter.loads)
|
||||
}
|
||||
result, err := second.ConnectUpstream(context.Background(), api.UpstreamConnectRequest{Host: "play.example", Upstream: "backend"})
|
||||
if err != nil {
|
||||
t.Fatalf("ConnectUpstream() error = %v", err)
|
||||
}
|
||||
if !result.Handled || result.Conn == nil {
|
||||
t.Fatalf("ConnectUpstream() = %+v, want restored handler conn", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptorPanicIsRecovered(t *testing.T) {
|
||||
handler := &upstreamHandler{
|
||||
pluginID: "acceptor",
|
||||
accept: func(api.UpstreamConnectRequest) bool {
|
||||
panic("boom")
|
||||
},
|
||||
}
|
||||
accepted, err := handler.accepts(api.UpstreamConnectRequest{})
|
||||
if err == nil {
|
||||
t.Fatal("accepts() error = nil, want panic error")
|
||||
}
|
||||
if accepted {
|
||||
t.Fatal("accepts() accepted = true, want false")
|
||||
}
|
||||
if handler.panics.Load() != 1 {
|
||||
t.Fatalf("panics = %d, want 1", handler.panics.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func newManagerForTest(t *testing.T, adapter RuntimeAdapter) *Manager {
|
||||
t.Helper()
|
||||
db := openPluginManagerTestDB(t)
|
||||
return New(Options{
|
||||
DB: db,
|
||||
ArtifactRoot: t.TempDir(),
|
||||
Adapter: adapter,
|
||||
})
|
||||
}
|
||||
|
||||
func openPluginManagerTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := admindb.Open(filepath.Join(t.TempDir(), "gateway.sqlite3"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := admindb.Migrate(db); err != nil {
|
||||
t.Fatalf("Migrate() error = %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func uploadTestArtifact(t *testing.T, manager *Manager, pluginID string) ArtifactRecord {
|
||||
t.Helper()
|
||||
packagePath := writeTestMCGP(t, map[string][]byte{
|
||||
"manifest.json": testManifestBytes(t, pluginID),
|
||||
"plugin.so": []byte("fake plugin bytes " + pluginID),
|
||||
})
|
||||
artifact, err := manager.UploadArtifact(context.Background(), ArtifactUpload{
|
||||
SourcePath: packagePath,
|
||||
FileName: pluginID + ".mcgp",
|
||||
Actor: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UploadArtifact(%s) error = %v", pluginID, err)
|
||||
}
|
||||
return artifact
|
||||
}
|
||||
|
||||
type fakeAdapter struct {
|
||||
loads int
|
||||
handlers map[string]api.UpstreamConnectHandler
|
||||
loadErr error
|
||||
loadErrs map[string]error
|
||||
}
|
||||
|
||||
func (a *fakeAdapter) Load(_ context.Context, artifact ArtifactRecord, _ PluginRecord, gateway *Gateway) (api.Plugin, error) {
|
||||
a.loads++
|
||||
if a.loadErr != nil {
|
||||
return nil, a.loadErr
|
||||
}
|
||||
if a.loadErrs != nil && a.loadErrs[artifact.PluginID] != nil {
|
||||
return nil, a.loadErrs[artifact.PluginID]
|
||||
}
|
||||
handler := api.UpstreamConnectHandler(func(api.UpstreamConnectRequest) (net.Conn, error) {
|
||||
return newMemoryConn(), nil
|
||||
})
|
||||
if a.handlers != nil && a.handlers[artifact.PluginID] != nil {
|
||||
handler = a.handlers[artifact.PluginID]
|
||||
}
|
||||
if err := api.RegisterHookHandler(
|
||||
gateway,
|
||||
api.HookUpstreamConnect,
|
||||
func(api.UpstreamConnectRequest) bool { return true },
|
||||
handler,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &fakePlugin{}, nil
|
||||
}
|
||||
|
||||
type fakePlugin struct {
|
||||
api.AbstractPlugin
|
||||
}
|
||||
|
||||
func newMemoryConn() net.Conn {
|
||||
left, right := net.Pipe()
|
||||
_ = right.Close()
|
||||
return left
|
||||
}
|
||||
337
internal/pluginmanager/repository.go
Normal file
337
internal/pluginmanager/repository.go
Normal file
@@ -0,0 +1,337 @@
|
||||
package pluginmanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Repository struct {
|
||||
db *sql.DB
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewRepository(db *sql.DB) Repository {
|
||||
return Repository{
|
||||
db: db,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
func NewRepositoryWithClock(db *sql.DB, now func() time.Time) Repository {
|
||||
repo := NewRepository(db)
|
||||
if now != nil {
|
||||
repo.now = now
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
func (r Repository) SaveArtifact(ctx context.Context, artifact ArtifactRecord) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO plugin_artifacts(
|
||||
id, plugin_id, version, file_name, file_path, sha256, package_sha256, size_bytes,
|
||||
artifact_type, runtime_type, runtime_entry, status, metadata_json,
|
||||
capabilities_summary_json, extension_points_json, api_version, go_version, go_os, go_arch,
|
||||
uploaded_by, error, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
file_name = excluded.file_name,
|
||||
file_path = excluded.file_path,
|
||||
package_sha256 = excluded.package_sha256,
|
||||
status = excluded.status,
|
||||
metadata_json = excluded.metadata_json,
|
||||
capabilities_summary_json = excluded.capabilities_summary_json,
|
||||
extension_points_json = excluded.extension_points_json,
|
||||
uploaded_by = excluded.uploaded_by,
|
||||
error = excluded.error,
|
||||
updated_at = excluded.updated_at`,
|
||||
artifact.ID, artifact.PluginID, artifact.Version, artifact.FileName, artifact.FilePath, artifact.SHA256, artifact.PackageSHA256, artifact.SizeBytes,
|
||||
artifact.ArtifactType, artifact.RuntimeType, artifact.RuntimeEntry, artifact.Status, artifact.MetadataJSON,
|
||||
artifact.CapabilitiesSummaryJSON, artifact.ExtensionPointsJSON, artifact.APIVersion, artifact.GoVersion, artifact.GOOS, artifact.GOARCH,
|
||||
artifact.UploadedBy, artifact.Error, artifact.CreatedAt, artifact.UpdatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r Repository) Artifact(ctx context.Context, id string) (ArtifactRecord, error) {
|
||||
row := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, plugin_id, version, file_name, file_path, sha256, package_sha256, size_bytes,
|
||||
artifact_type, runtime_type, runtime_entry, status, metadata_json,
|
||||
capabilities_summary_json, extension_points_json, api_version, go_version, go_os, go_arch,
|
||||
uploaded_by, error, created_at, updated_at
|
||||
FROM plugin_artifacts
|
||||
WHERE id = ?`, id)
|
||||
return scanArtifact(row)
|
||||
}
|
||||
|
||||
func (r Repository) ListArtifacts(ctx context.Context, pluginID string) ([]ArtifactRecord, error) {
|
||||
query := `
|
||||
SELECT id, plugin_id, version, file_name, file_path, sha256, package_sha256, size_bytes,
|
||||
artifact_type, runtime_type, runtime_entry, status, metadata_json,
|
||||
capabilities_summary_json, extension_points_json, api_version, go_version, go_os, go_arch,
|
||||
uploaded_by, error, created_at, updated_at
|
||||
FROM plugin_artifacts`
|
||||
var args []any
|
||||
if pluginID != "" {
|
||||
query += ` WHERE plugin_id = ?`
|
||||
args = append(args, pluginID)
|
||||
}
|
||||
query += ` ORDER BY created_at DESC, id DESC`
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var artifacts []ArtifactRecord
|
||||
for rows.Next() {
|
||||
artifact, err := scanArtifact(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
artifacts = append(artifacts, artifact)
|
||||
}
|
||||
return artifacts, rows.Err()
|
||||
}
|
||||
|
||||
func (r Repository) UpsertDesired(ctx context.Context, actor, pluginID, artifactID, desiredState, configJSON string, priority int) (PluginRecord, error) {
|
||||
if desiredState == "" {
|
||||
desiredState = DesiredDisabled
|
||||
}
|
||||
if configJSON == "" {
|
||||
configJSON = "{}"
|
||||
}
|
||||
if !json.Valid([]byte(configJSON)) {
|
||||
return PluginRecord{}, errors.New("config_json must be valid JSON")
|
||||
}
|
||||
if priority == 0 {
|
||||
priority = DefaultPriority
|
||||
}
|
||||
switch desiredState {
|
||||
case DesiredEnabled, DesiredDisabled, DesiredDeleted:
|
||||
default:
|
||||
return PluginRecord{}, errors.New("invalid desired_state")
|
||||
}
|
||||
artifact, err := r.Artifact(ctx, artifactID)
|
||||
if err != nil {
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
if artifact.PluginID != pluginID {
|
||||
return PluginRecord{}, errors.New("artifact plugin_id does not match")
|
||||
}
|
||||
|
||||
now := r.now().Unix()
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var existing PluginRecord
|
||||
row := tx.QueryRowContext(ctx, `
|
||||
SELECT id, desired_artifact_id, active_artifact_id, loaded_artifact_id, desired_state, runtime_state,
|
||||
priority, config_json, desired_generation, applied_generation, last_error,
|
||||
runtime_summary_json, dispatch_summary_json, created_at, updated_at, updated_by
|
||||
FROM plugins WHERE id = ?`, pluginID)
|
||||
err = scanPluginRow(row, &existing)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
|
||||
nextGeneration := int64(1)
|
||||
createdAt := now
|
||||
if err == nil {
|
||||
nextGeneration = existing.DesiredGeneration + 1
|
||||
createdAt = existing.CreatedAt
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO plugin_config_snapshots(plugin_id, artifact_id, config_json, desired_state, priority, desired_generation, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
existing.ID, existing.DesiredArtifactID, existing.ConfigJSON, existing.DesiredState, existing.Priority, existing.DesiredGeneration, actor, now); err != nil {
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO plugins(
|
||||
id, desired_artifact_id, active_artifact_id, loaded_artifact_id, desired_state, runtime_state,
|
||||
priority, config_json, desired_generation, applied_generation, last_error,
|
||||
runtime_summary_json, dispatch_summary_json, deleted_at, created_at, updated_at, updated_by
|
||||
) VALUES (?, ?, '', '', ?, ?, ?, ?, ?, 0, '', '{}', '{}', 0, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
desired_artifact_id = excluded.desired_artifact_id,
|
||||
desired_state = excluded.desired_state,
|
||||
priority = excluded.priority,
|
||||
config_json = excluded.config_json,
|
||||
desired_generation = excluded.desired_generation,
|
||||
deleted_at = CASE WHEN excluded.desired_state = 'deleted' THEN excluded.updated_at ELSE 0 END,
|
||||
updated_at = excluded.updated_at,
|
||||
updated_by = excluded.updated_by`,
|
||||
pluginID, artifactID, desiredState, RuntimeDisabled, priority, configJSON, nextGeneration, createdAt, now, actor); err != nil {
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
return r.Plugin(ctx, pluginID)
|
||||
}
|
||||
|
||||
func (r Repository) Plugin(ctx context.Context, id string) (PluginRecord, error) {
|
||||
row := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, desired_artifact_id, active_artifact_id, loaded_artifact_id, desired_state, runtime_state,
|
||||
priority, config_json, desired_generation, applied_generation, last_error,
|
||||
runtime_summary_json, dispatch_summary_json, created_at, updated_at, updated_by
|
||||
FROM plugins
|
||||
WHERE id = ? AND desired_state <> 'deleted'`, id)
|
||||
var plugin PluginRecord
|
||||
if err := scanPluginRow(row, &plugin); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return PluginRecord{}, ErrPluginNotFound
|
||||
}
|
||||
return PluginRecord{}, err
|
||||
}
|
||||
return plugin, nil
|
||||
}
|
||||
|
||||
func (r Repository) ListPlugins(ctx context.Context) ([]PluginRecord, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, desired_artifact_id, active_artifact_id, loaded_artifact_id, desired_state, runtime_state,
|
||||
priority, config_json, desired_generation, applied_generation, last_error,
|
||||
runtime_summary_json, dispatch_summary_json, created_at, updated_at, updated_by
|
||||
FROM plugins
|
||||
WHERE desired_state <> 'deleted'
|
||||
ORDER BY priority ASC, id ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var plugins []PluginRecord
|
||||
for rows.Next() {
|
||||
var plugin PluginRecord
|
||||
if err := scanPluginRow(rows, &plugin); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plugins = append(plugins, plugin)
|
||||
}
|
||||
return plugins, rows.Err()
|
||||
}
|
||||
|
||||
func (r Repository) DesiredEnabled(ctx context.Context) ([]PluginRecord, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, desired_artifact_id, active_artifact_id, loaded_artifact_id, desired_state, runtime_state,
|
||||
priority, config_json, desired_generation, applied_generation, last_error,
|
||||
runtime_summary_json, dispatch_summary_json, created_at, updated_at, updated_by
|
||||
FROM plugins
|
||||
WHERE desired_state = 'enabled'
|
||||
ORDER BY priority ASC, id ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var plugins []PluginRecord
|
||||
for rows.Next() {
|
||||
var plugin PluginRecord
|
||||
if err := scanPluginRow(rows, &plugin); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plugins = append(plugins, plugin)
|
||||
}
|
||||
return plugins, rows.Err()
|
||||
}
|
||||
|
||||
func (r Repository) MarkRuntime(ctx context.Context, pluginID, runtimeState, activeArtifactID, loadedArtifactID string, appliedGeneration int64, lastError string, runtimeSummary, dispatchSummary any) error {
|
||||
now := r.now().Unix()
|
||||
runtimeJSON, err := marshalDefaultObject(runtimeSummary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dispatchJSON, err := marshalDefaultObject(dispatchSummary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = r.db.ExecContext(ctx, `
|
||||
UPDATE plugins
|
||||
SET runtime_state = ?, active_artifact_id = ?, loaded_artifact_id = ?, applied_generation = ?,
|
||||
last_error = ?, runtime_summary_json = ?, dispatch_summary_json = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
runtimeState, activeArtifactID, loadedArtifactID, appliedGeneration, lastError, runtimeJSON, dispatchJSON, now, pluginID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r Repository) UpdateArtifactStatus(ctx context.Context, artifactID, status, message string) error {
|
||||
_, err := r.db.ExecContext(ctx, `UPDATE plugin_artifacts SET status = ?, error = ?, updated_at = ? WHERE id = ?`,
|
||||
status, message, r.now().Unix(), artifactID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r Repository) RecordOperation(ctx context.Context, pluginID, artifactID, operation, status, actor, message string, metadata any) error {
|
||||
metadataJSON, err := marshalDefaultObject(metadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = r.db.ExecContext(ctx, `
|
||||
INSERT INTO plugin_operations(plugin_id, artifact_id, operation, status, actor, message, metadata_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
pluginID, artifactID, operation, status, actor, message, metadataJSON, r.now().Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
func (r Repository) DispatchPlan(ctx context.Context) (DispatchPlan, error) {
|
||||
plugins, err := r.ListPlugins(ctx)
|
||||
if err != nil {
|
||||
return DispatchPlan{}, err
|
||||
}
|
||||
plan := DispatchPlan{UpdatedAt: r.now().Unix()}
|
||||
for _, plugin := range plugins {
|
||||
if plugin.DispatchSummaryJSON == "" || plugin.RuntimeState != RuntimeEnabled {
|
||||
continue
|
||||
}
|
||||
var summaries []DispatchHandlerSummary
|
||||
if err := json.Unmarshal([]byte(plugin.DispatchSummaryJSON), &summaries); err == nil {
|
||||
plan.Handlers = append(plan.Handlers, summaries...)
|
||||
}
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanArtifact(row rowScanner) (ArtifactRecord, error) {
|
||||
var artifact ArtifactRecord
|
||||
err := row.Scan(
|
||||
&artifact.ID, &artifact.PluginID, &artifact.Version, &artifact.FileName, &artifact.FilePath, &artifact.SHA256, &artifact.PackageSHA256, &artifact.SizeBytes,
|
||||
&artifact.ArtifactType, &artifact.RuntimeType, &artifact.RuntimeEntry, &artifact.Status, &artifact.MetadataJSON,
|
||||
&artifact.CapabilitiesSummaryJSON, &artifact.ExtensionPointsJSON, &artifact.APIVersion, &artifact.GoVersion, &artifact.GOOS, &artifact.GOARCH,
|
||||
&artifact.UploadedBy, &artifact.Error, &artifact.CreatedAt, &artifact.UpdatedAt,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ArtifactRecord{}, ErrArtifactNotFound
|
||||
}
|
||||
return artifact, err
|
||||
}
|
||||
|
||||
func scanPluginRow(row rowScanner, plugin *PluginRecord) error {
|
||||
return row.Scan(
|
||||
&plugin.ID, &plugin.DesiredArtifactID, &plugin.ActiveArtifactID, &plugin.LoadedArtifactID, &plugin.DesiredState, &plugin.RuntimeState,
|
||||
&plugin.Priority, &plugin.ConfigJSON, &plugin.DesiredGeneration, &plugin.AppliedGeneration, &plugin.LastError,
|
||||
&plugin.RuntimeSummaryJSON, &plugin.DispatchSummaryJSON, &plugin.CreatedAt, &plugin.UpdatedAt, &plugin.UpdatedBy,
|
||||
)
|
||||
}
|
||||
|
||||
func marshalDefaultObject(value any) (string, error) {
|
||||
if value == nil {
|
||||
return "{}", nil
|
||||
}
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(data) == 0 || string(data) == "null" {
|
||||
return "{}", nil
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
233
internal/pluginmanager/types.go
Normal file
233
internal/pluginmanager/types.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package pluginmanager
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tursom/mc-gateway/plugin/api"
|
||||
)
|
||||
|
||||
const (
|
||||
SchemaVersion = "mc-gateway.plugin/v1"
|
||||
APIVersion = "plugin-api/v1"
|
||||
|
||||
ArtifactTypeBinary = "binary"
|
||||
RuntimeGoPlugin = "go-plugin"
|
||||
RuntimeEntry = "plugin.so"
|
||||
|
||||
ExtensionUpstreamConnect = "upstream.connect/v1"
|
||||
|
||||
ArtifactStatusUploaded = "uploaded"
|
||||
ArtifactStatusValidated = "validated"
|
||||
ArtifactStatusLoadable = "loadable"
|
||||
ArtifactStatusLoaded = "loaded"
|
||||
ArtifactStatusRejected = "rejected"
|
||||
ArtifactStatusDeleted = "deleted"
|
||||
|
||||
DesiredEnabled = "enabled"
|
||||
DesiredDisabled = "disabled"
|
||||
DesiredDeleted = "deleted"
|
||||
|
||||
RuntimeNotLoaded = "not_loaded"
|
||||
RuntimeLoaded = "loaded"
|
||||
RuntimeEnabled = "enabled"
|
||||
RuntimeFailed = "failed"
|
||||
RuntimeDisabled = "disabled"
|
||||
|
||||
DefaultPriority = 100
|
||||
DefaultHandlerTimeout = 3 * time.Second
|
||||
DefaultManifestMaxBytes = 256 * 1024
|
||||
DefaultPackageMaxBytes = 64 * 1024 * 1024
|
||||
DefaultPackageMaxEntries = 2048
|
||||
DefaultExtractedMaxBytes = 256 * 1024 * 1024
|
||||
DefaultNonRuntimeMaxBytes = 16 * 1024 * 1024
|
||||
)
|
||||
|
||||
var (
|
||||
ErrArtifactNotFound = errors.New("plugin artifact not found")
|
||||
ErrPluginNotFound = errors.New("plugin not found")
|
||||
)
|
||||
|
||||
type Manifest struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
ArtifactType string `json:"artifact_type"`
|
||||
Runtime RuntimeManifest `json:"runtime"`
|
||||
APIVersion string `json:"api_version"`
|
||||
SDKModule string `json:"sdk_module"`
|
||||
SDKModuleVersion string `json:"sdk_module_version"`
|
||||
GoVersion string `json:"go_version"`
|
||||
GOOS string `json:"go_os"`
|
||||
GOARCH string `json:"go_arch"`
|
||||
ExtensionPoints []ExtensionPoint `json:"extension_points"`
|
||||
Capabilities json.RawMessage `json:"capabilities"`
|
||||
RuntimeLimits RuntimeLimits `json:"runtime_limits"`
|
||||
ConfigSchema json.RawMessage `json:"config_schema"`
|
||||
SupplyChain json.RawMessage `json:"supply_chain"`
|
||||
}
|
||||
|
||||
type RuntimeManifest struct {
|
||||
Type string `json:"type"`
|
||||
Entry string `json:"entry"`
|
||||
EntrySymbol string `json:"entry_symbol"`
|
||||
MetadataSymbol string `json:"metadata_symbol"`
|
||||
}
|
||||
|
||||
type ExtensionPoint struct {
|
||||
Type string `json:"type"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
type RuntimeLimits struct {
|
||||
HandlerTimeoutMS int `json:"handler_timeout_ms"`
|
||||
}
|
||||
|
||||
type ArtifactRecord struct {
|
||||
ID string `json:"id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
Version string `json:"version"`
|
||||
FileName string `json:"file_name"`
|
||||
FilePath string `json:"file_path"`
|
||||
SHA256 string `json:"sha256"`
|
||||
PackageSHA256 string `json:"package_sha256"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
ArtifactType string `json:"artifact_type"`
|
||||
RuntimeType string `json:"runtime_type"`
|
||||
RuntimeEntry string `json:"runtime_entry"`
|
||||
Status string `json:"status"`
|
||||
MetadataJSON string `json:"metadata_json"`
|
||||
CapabilitiesSummaryJSON string `json:"capabilities_summary_json"`
|
||||
ExtensionPointsJSON string `json:"extension_points_json"`
|
||||
APIVersion string `json:"api_version"`
|
||||
GoVersion string `json:"go_version"`
|
||||
GOOS string `json:"go_os"`
|
||||
GOARCH string `json:"go_arch"`
|
||||
UploadedBy string `json:"uploaded_by"`
|
||||
Error string `json:"error"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type PluginRecord struct {
|
||||
ID string `json:"id"`
|
||||
DesiredArtifactID string `json:"desired_artifact_id"`
|
||||
ActiveArtifactID string `json:"active_artifact_id"`
|
||||
LoadedArtifactID string `json:"loaded_artifact_id"`
|
||||
DesiredState string `json:"desired_state"`
|
||||
RuntimeState string `json:"runtime_state"`
|
||||
Priority int `json:"priority"`
|
||||
ConfigJSON string `json:"config_json"`
|
||||
DesiredGeneration int64 `json:"desired_generation"`
|
||||
AppliedGeneration int64 `json:"applied_generation"`
|
||||
LastError string `json:"last_error"`
|
||||
RuntimeSummaryJSON string `json:"runtime_summary_json"`
|
||||
DispatchSummaryJSON string `json:"dispatch_summary_json"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
UpdatedBy string `json:"updated_by"`
|
||||
}
|
||||
|
||||
type OperationRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Operation string `json:"operation"`
|
||||
Status string `json:"status"`
|
||||
Actor string `json:"actor"`
|
||||
Message string `json:"message"`
|
||||
MetadataJSON string `json:"metadata_json"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type ConfigSnapshot struct {
|
||||
ID int64 `json:"id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
ConfigJSON string `json:"config_json"`
|
||||
DesiredState string `json:"desired_state"`
|
||||
Priority int `json:"priority"`
|
||||
DesiredGeneration int64 `json:"desired_generation"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type DispatchPlan struct {
|
||||
Handlers []DispatchHandlerSummary `json:"handlers"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type DispatchHandlerSummary struct {
|
||||
PluginID string `json:"plugin_id"`
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Priority int `json:"priority"`
|
||||
HandlerID string `json:"handler_id"`
|
||||
ExtensionPoint string `json:"extension_point"`
|
||||
TimeoutMS int64 `json:"timeout_ms"`
|
||||
Calls uint64 `json:"calls"`
|
||||
Errors uint64 `json:"errors"`
|
||||
Panics uint64 `json:"panics"`
|
||||
Timeouts uint64 `json:"timeouts"`
|
||||
}
|
||||
|
||||
type UpstreamResult struct {
|
||||
Conn net.Conn
|
||||
Handled bool
|
||||
}
|
||||
|
||||
type Gateway struct {
|
||||
PluginID string
|
||||
handleConn func(net.Conn)
|
||||
wg *sync.WaitGroup
|
||||
hooks map[string]any
|
||||
}
|
||||
|
||||
func NewGateway(pluginID string, handleConn func(net.Conn), wg *sync.WaitGroup) *Gateway {
|
||||
return &Gateway{
|
||||
PluginID: pluginID,
|
||||
handleConn: handleConn,
|
||||
wg: wg,
|
||||
hooks: make(map[string]any),
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) RegisteredHooks() map[string]any {
|
||||
copied := make(map[string]any, len(g.hooks))
|
||||
for key, value := range g.hooks {
|
||||
copied[key] = value
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
func (g *Gateway) Hook(hook string, handler any) error {
|
||||
g.hooks[hook] = handler
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *Gateway) HandleConn(conn net.Conn) {
|
||||
if g.handleConn != nil {
|
||||
g.handleConn(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) ExitWaitGroup() *sync.WaitGroup {
|
||||
if g.wg == nil {
|
||||
g.wg = &sync.WaitGroup{}
|
||||
}
|
||||
return g.wg
|
||||
}
|
||||
|
||||
func (g *Gateway) LegacyUpstreamHandler() (api.HookHandler[func(net.Conn, string) bool, func(net.Conn, string) (net.Conn, error)], bool) {
|
||||
handler, ok := g.hooks[api.HookUpstream.Key()].(api.HookHandler[func(net.Conn, string) bool, func(net.Conn, string) (net.Conn, error)])
|
||||
return handler, ok
|
||||
}
|
||||
|
||||
func (g *Gateway) UpstreamConnectHandler() (api.HookHandler[api.UpstreamConnectAcceptor, api.UpstreamConnectHandler], bool) {
|
||||
handler, ok := g.hooks[api.HookUpstreamConnect.Key()].(api.HookHandler[api.UpstreamConnectAcceptor, api.UpstreamConnectHandler])
|
||||
return handler, ok
|
||||
}
|
||||
26
package-lock.json
generated
Normal file
26
package-lock.json
generated
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "mc-gateway",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"devDependencies": {
|
||||
"typescript": "6.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
package.json
Normal file
9
package.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"scripts": {
|
||||
"build:admin": "tsc -p tsconfig.admin.json",
|
||||
"check:admin": "tsc -p tsconfig.admin.json --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,9 @@ func TestAbstractPluginDefaults(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHookTypesAndHandlers(t *testing.T) {
|
||||
if got := HookUpstreamConnect.Key(); got != "upstream.connect/v1" {
|
||||
t.Fatalf("HookUpstreamConnect.Key() = %q, want upstream.connect/v1", got)
|
||||
}
|
||||
if got := HookUpstream.Key(); got != "upstream" {
|
||||
t.Fatalf("HookUpstream.Key() = %q, want upstream", got)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"unsafe"
|
||||
@@ -8,6 +9,8 @@ import (
|
||||
|
||||
var (
|
||||
UnsupportedHookType = errors.New("unsupported hook type")
|
||||
ErrPass = errors.New("plugin handler pass")
|
||||
ErrBlocked = errors.New("plugin handler blocked")
|
||||
)
|
||||
|
||||
type (
|
||||
@@ -19,9 +22,28 @@ type (
|
||||
acceptor Accept
|
||||
handler Handler
|
||||
}
|
||||
|
||||
UpstreamConnectRequest struct {
|
||||
Context context.Context
|
||||
Source net.Conn
|
||||
Host string
|
||||
Upstream string
|
||||
InitialData []byte
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
UpstreamConnectAcceptor func(UpstreamConnectRequest) bool
|
||||
UpstreamConnectHandler func(UpstreamConnectRequest) (net.Conn, error)
|
||||
)
|
||||
|
||||
var (
|
||||
HookUpstreamConnect = HookType[
|
||||
UpstreamConnectAcceptor,
|
||||
UpstreamConnectHandler,
|
||||
]{
|
||||
key: "upstream.connect/v1",
|
||||
}
|
||||
|
||||
HookUpstream = HookType[
|
||||
func(source net.Conn, host string) bool,
|
||||
func(source net.Conn, host string) (net.Conn, error),
|
||||
|
||||
19
tsconfig.admin.json
Normal file
19
tsconfig.admin.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"declaration": false,
|
||||
"lib": ["DOM", "ES2022"],
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"noEmitOnError": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"outDir": "cmd/gateway/admin_static/js",
|
||||
"removeComments": true,
|
||||
"rootDir": "cmd/gateway/admin_frontend/src",
|
||||
"strict": true,
|
||||
"target": "ES2022"
|
||||
},
|
||||
"include": ["cmd/gateway/admin_frontend/src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user