Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f508ecc1b9 | ||
|
|
7a5664ab89 | ||
|
|
e4dfcb62cb | ||
|
|
f22005ea37 | ||
|
|
54f5c322e9 | ||
|
|
f3fb924a3a | ||
|
|
91eee02243 | ||
|
|
629d6d5dbc | ||
|
|
b822993489 | ||
|
|
eae2319328 | ||
|
|
ae8706f8c8 | ||
|
|
f4a11fb770 | ||
|
|
c21edfdf1a | ||
|
|
ecb682bb41 | ||
|
|
bf6db8c455 | ||
|
|
4fbba07b27 | ||
|
|
49381901df | ||
|
|
04ec53bc51 | ||
|
|
13ab1f6964 | ||
|
|
ada051341d | ||
|
|
935fe4b808 | ||
|
|
ca9241595e | ||
|
|
b46d057b80 | ||
|
|
ef9cca7440 | ||
|
|
9e80512948 | ||
|
|
55c2f0a888 | ||
|
|
8530df6cdf | ||
|
|
0e5e0ced30 | ||
|
|
c6050d6824 | ||
|
|
9bc1be1020 | ||
|
|
e0a33c3a0d | ||
|
|
095d69d8a3 | ||
|
|
48cea3d046 |
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
|
||||
214
.github/workflows/build.yml
vendored
Normal file
214
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,214 @@
|
||||
# This workflow will build a golang project
|
||||
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
|
||||
|
||||
name: Go
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
pull_request:
|
||||
branches: [ "master" ]
|
||||
|
||||
jobs:
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# Linux builds
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
name: linux-amd64
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
name: linux-arm64
|
||||
- goos: linux
|
||||
goarch: arm
|
||||
goarm: 7
|
||||
name: linux-armv7
|
||||
- goos: linux
|
||||
goarch: arm
|
||||
goarm: 6
|
||||
name: linux-armv6
|
||||
- goos: linux
|
||||
goarch: 386
|
||||
name: linux-386
|
||||
- goos: linux
|
||||
goarch: loong64
|
||||
name: linux-loong64
|
||||
- goos: linux
|
||||
goarch: mips
|
||||
name: linux-mips
|
||||
- goos: linux
|
||||
goarch: mips64
|
||||
name: linux-mips64
|
||||
- goos: linux
|
||||
goarch: mips64le
|
||||
name: linux-mips64le
|
||||
- goos: linux
|
||||
goarch: mipsle
|
||||
name: linux-mipsle
|
||||
- goos: linux
|
||||
goarch: ppc64
|
||||
name: linux-ppc64
|
||||
- goos: linux
|
||||
goarch: ppc64le
|
||||
name: linux-ppc64le
|
||||
- goos: linux
|
||||
goarch: riscv64
|
||||
name: linux-riscv64
|
||||
- goos: linux
|
||||
goarch: s390x
|
||||
name: linux-s390x
|
||||
|
||||
# Windows builds
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
name: windows-amd64
|
||||
ext: .exe
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
name: windows-arm64
|
||||
ext: .exe
|
||||
- goos: windows
|
||||
goarch: 386
|
||||
name: windows-386
|
||||
ext: .exe
|
||||
|
||||
# macOS builds
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
name: darwin-amd64
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
name: darwin-arm64
|
||||
|
||||
# FreeBSD builds
|
||||
- goos: freebsd
|
||||
goarch: amd64
|
||||
name: freebsd-amd64
|
||||
- goos: freebsd
|
||||
goarch: arm64
|
||||
name: freebsd-arm64
|
||||
- goos: freebsd
|
||||
goarch: 386
|
||||
name: freebsd-386
|
||||
- goos: freebsd
|
||||
goarch: arm
|
||||
name: freebsd-arm
|
||||
- goos: freebsd
|
||||
goarch: riscv64
|
||||
name: freebsd-riscv64
|
||||
|
||||
- goos: aix
|
||||
goarch: ppc64
|
||||
name: aix-ppc64
|
||||
- goos: dragonfly
|
||||
goarch: amd64
|
||||
name: dragonfly-amd64
|
||||
- goos: illumos
|
||||
goarch: amd64
|
||||
name: illumos-amd64
|
||||
- goos: netbsd
|
||||
goarch: 386
|
||||
name: netbsd-386
|
||||
- goos: netbsd
|
||||
goarch: amd64
|
||||
name: netbsd-amd64
|
||||
- goos: netbsd
|
||||
goarch: arm
|
||||
name: netbsd-arm
|
||||
- goos: netbsd
|
||||
goarch: arm64
|
||||
name: netbsd-arm64
|
||||
- goos: openbsd
|
||||
goarch: 386
|
||||
name: openbsd-386
|
||||
- goos: openbsd
|
||||
goarch: amd64
|
||||
name: openbsd-amd64
|
||||
- goos: openbsd
|
||||
goarch: arm
|
||||
name: openbsd-arm
|
||||
- goos: openbsd
|
||||
goarch: arm64
|
||||
name: openbsd-arm64
|
||||
- goos: openbsd
|
||||
goarch: ppc64
|
||||
name: openbsd-ppc64
|
||||
- goos: openbsd
|
||||
goarch: riscv64
|
||||
name: openbsd-riscv64
|
||||
- goos: plan9
|
||||
goarch: 386
|
||||
name: plan9-386
|
||||
- goos: plan9
|
||||
goarch: amd64
|
||||
name: plan9-amd64
|
||||
- goos: plan9
|
||||
goarch: arm
|
||||
name: plan9-arm
|
||||
- goos: solaris
|
||||
goarch: amd64
|
||||
name: solaris-amd64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '1.23.1'
|
||||
|
||||
- name: Build Gateway
|
||||
run: go build -ldflags="-s -w" -o gateway-${{ matrix.name }}${{ matrix.ext }} ./cmd/gateway
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
GOARM: ${{ matrix.goarm }}
|
||||
|
||||
- name: Build KCP
|
||||
run: go build -ldflags="-s -w" -o kcp-${{ matrix.name }}${{ matrix.ext }} ./cmd/kcp
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
GOARM: ${{ matrix.goarm }}
|
||||
|
||||
- name: Build QUIC
|
||||
run: go build -ldflags="-s -w" -o quic-${{ matrix.name }}${{ matrix.ext }} ./cmd/quic
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
GOARM: ${{ matrix.goarm }}
|
||||
|
||||
- name: Upload artifacts artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: binaries-${{ matrix.name }}
|
||||
path: |
|
||||
gateway-${{ matrix.name }}${{ matrix.ext }}
|
||||
kcp-${{ matrix.name }}${{ matrix.ext }}
|
||||
quic-${{ matrix.name }}${{ matrix.ext }}
|
||||
|
||||
# 合并所有构建产物
|
||||
merge-artifacts:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ./artifacts
|
||||
merge-multiple: true # 合并所有 artifacts
|
||||
|
||||
- name: Display structure
|
||||
run: ls -la ./artifacts
|
||||
|
||||
- name: Upload merged artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mc-gateway-all-platforms
|
||||
path: ./artifacts/*
|
||||
retention-days: 30
|
||||
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
|
||||
28
.github/workflows/go.yml
vendored
28
.github/workflows/go.yml
vendored
@@ -1,28 +0,0 @@
|
||||
# This workflow will build a golang project
|
||||
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
|
||||
|
||||
name: Go
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
pull_request:
|
||||
branches: [ "master" ]
|
||||
|
||||
jobs:
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '1.23.1'
|
||||
|
||||
- name: Build
|
||||
run: go build -ldflags="-s -w" -v ./...
|
||||
|
||||
- name: Test
|
||||
run: go test -v ./...
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -1,2 +1,12 @@
|
||||
config.json
|
||||
/gateway
|
||||
.vscode
|
||||
node_modules/
|
||||
cmd/gateway/admin_static/js/
|
||||
|
||||
config.toml
|
||||
/gateway*
|
||||
/kcp*
|
||||
/quic*
|
||||
/logs/
|
||||
/data/
|
||||
|
||||
.omc
|
||||
|
||||
47
Dockerfile
Normal file
47
Dockerfile
Normal file
@@ -0,0 +1,47 @@
|
||||
# Dockerfile 构建网关二进制、编译管理前端,并打包带 SQLite 友好默认值的运行镜像。
|
||||
|
||||
# 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"]
|
||||
143
README.md
143
README.md
@@ -1,2 +1,143 @@
|
||||
# mc-gateway
|
||||
This project is a Minecraft game server gateway. It allows you to access multiple game servers through different hostnames, using the same port.
|
||||
|
||||
一个简易的 Minecraft 网关,通过客户端握手里的 host 将流量转发到对应的后端 Minecraft 服务器。
|
||||
|
||||
## 启动
|
||||
|
||||
mc-gateway 默认不依赖配置文件。直接启动后会在 `25565` 端口同时提供 Minecraft TCP 转发入口和后台管理入口:
|
||||
|
||||
- Admin 页面:`/admin/`
|
||||
- Admin API:`/admin/api`
|
||||
- SQLite 数据库:`mc-gateway.sqlite3`
|
||||
|
||||
首次启动时,如果用户表为空,可以通过 Admin 页面初始化管理员账号。也可以用 `MC_GATEWAY_ADMIN_PASSWORD` 在启动时创建默认管理员用户 `admin`。
|
||||
|
||||
### 启动期环境变量
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `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
|
||||
```
|
||||
|
||||
## 权限
|
||||
|
||||
后台管理内置三类角色:
|
||||
|
||||
| 角色 | 能力 |
|
||||
| --- | --- |
|
||||
| 管理员 | 管理用户、服务、路由和审计日志 |
|
||||
| 成员 | 查看状态,管理路由 |
|
||||
| 游客 | 查看当前路由 |
|
||||
|
||||
## 路由
|
||||
|
||||
路由记录存储在 SQLite 中,后台修改后会刷新内存快照。默认 fallback 路由的 host 为 `default`。
|
||||
|
||||
### upstream
|
||||
|
||||
路由上游支持多种协议。TCP 上游直接填写地址,其他协议在地址前加协议前缀:
|
||||
|
||||
| 协议 | 前缀 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| tcp | 无 | 原始 TCP 连接 |
|
||||
| kcp | `kcp://` | 使用 KCP 协议连接到服务器 |
|
||||
| quic | `quic://` | 使用 QUIC 协议连接到服务器 |
|
||||
| haproxy | `haproxy://` | 使用 HAProxy 协议连接到服务器 |
|
||||
|
||||
HAProxy 协议头会保存客户端真实 IP,适合需要在后端服务端获取真实客户端 IP 的场景。
|
||||
|
||||
## 可选服务
|
||||
|
||||
TCP/Admin listener 是基础入口,默认启用。KCP、QUIC、WebSocket 默认禁用,可以在后台管理中启用并配置端口和参数;配置修改后第一版按重启后生效处理。
|
||||
|
||||
| 服务 | 默认端口 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| TCP/Admin | `25565` | Minecraft TCP 转发和后台管理共享入口 |
|
||||
| KCP | `25565` | 可选 KCP 入口 |
|
||||
| QUIC | `25565` | 可选 QUIC 入口 |
|
||||
| WebSocket | `25566` | 可选 WebSocket 入口 |
|
||||
|
||||
## 日志
|
||||
|
||||
默认日志级别为 `info`,输出到标准输出。日志文件重开逻辑支持 logrotate 场景:
|
||||
|
||||
```logrotate
|
||||
/var/log/mc-gateway.log {
|
||||
copytruncate
|
||||
daily
|
||||
missingok
|
||||
rotate 14
|
||||
compress
|
||||
compresscmd /usr/bin/zstd
|
||||
compressext .zst
|
||||
compressoptions -T0 --long
|
||||
uncompresscmd /usr/bin/unzstd
|
||||
notifempty
|
||||
delaycompress
|
||||
dateext
|
||||
postrotate
|
||||
if [ -f /dev/shm/mc-gateway.pid ]; then
|
||||
kill -SIGHUP $(cat /dev/shm/mc-gateway.pid)
|
||||
fi
|
||||
endscript
|
||||
}
|
||||
```
|
||||
|
||||
62
cmd/gateway/admin_api.go
Normal file
62
cmd/gateway/admin_api.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// cmd/gateway/admin_api.go 组装 Admin API 的共享依赖,并提供嵌入式控制台使用的顶层 HTTP 路由。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tursom/mc-gateway/internal/adminhttp"
|
||||
)
|
||||
|
||||
func newAdminAPIHandler() http.HandlerFunc {
|
||||
// Admin API 的路径解析放在 internal/adminhttp 中,主包只提供各业务 handler。
|
||||
// 这样测试可以复用同一套路由表,而不会依赖真实监听器。
|
||||
return adminhttp.NewAPIHandler(adminStartup.AdminAPIPrefix, adminhttp.APIHandlers{
|
||||
SetupStatus: handleAdminSetupStatus,
|
||||
Setup: handleAdminSetup,
|
||||
Login: handleAdminLogin,
|
||||
Logout: handleAdminLogout,
|
||||
Me: handleAdminMe,
|
||||
Status: handleAdminStatus,
|
||||
|
||||
RoutesList: handleAdminRoutesList,
|
||||
RouteItem: handleAdminRouteItem,
|
||||
|
||||
ServicesList: handleAdminServicesList,
|
||||
ServiceItem: handleAdminServiceItem,
|
||||
|
||||
Metrics: handleAdminMetrics,
|
||||
|
||||
UsersList: handleAdminUsersList,
|
||||
UsersCreate: handleAdminUsersCreate,
|
||||
UserItem: handleAdminUserItem,
|
||||
|
||||
AuditLogs: handleAdminAuditLogs,
|
||||
|
||||
// 插件相关接口数量较多,统一在这里接入,确保嵌入式 UI 和远程 CLI
|
||||
// 看到的是同一套 Admin API 行为。
|
||||
PluginArtifacts: handleAdminPluginArtifacts,
|
||||
PluginArtifact: handleAdminPluginArtifact,
|
||||
PluginSources: handleAdminPluginSources,
|
||||
PluginBuilds: handleAdminPluginBuilds,
|
||||
PluginBuild: handleAdminPluginBuild,
|
||||
PluginGC: handleAdminPluginGC,
|
||||
PluginOperationsGC: handleAdminPluginOperationsGC,
|
||||
PluginsList: handleAdminPluginsList,
|
||||
PluginItem: handleAdminPluginItem,
|
||||
PluginAction: handleAdminPluginAction,
|
||||
PluginConfig: handleAdminPluginConfig,
|
||||
PluginSecrets: handleAdminPluginSecrets,
|
||||
PluginRollback: handleAdminPluginRollback,
|
||||
PluginOperations: handleAdminPluginOperations,
|
||||
PluginDraining: handleAdminPluginDraining,
|
||||
PluginDispatch: handleAdminPluginDispatchPlan,
|
||||
PluginGovernance: handleAdminPluginGovernance,
|
||||
PluginAdvisories: handleAdminPluginAdvisories,
|
||||
PluginDiagnostics: handleAdminPluginDiagnostics,
|
||||
PluginService: handleAdminPluginService,
|
||||
PluginRepositories: handleAdminPluginRepositories,
|
||||
PluginSupplyChain: handleAdminPluginSupplyChain,
|
||||
PluginInstrumentation: handleAdminPluginInstrumentation,
|
||||
})
|
||||
}
|
||||
565
cmd/gateway/admin_api_test.go
Normal file
565
cmd/gateway/admin_api_test.go
Normal file
@@ -0,0 +1,565 @@
|
||||
// cmd/gateway/admin_api_test.go 包含用于约束 admin api 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tursom/mc-gateway/internal/pluginmanager"
|
||||
)
|
||||
|
||||
func TestAdminSetupLoginAndPermissions(t *testing.T) {
|
||||
handler := newAdminTestHandler(t)
|
||||
|
||||
resp := adminTestRequest(t, handler, http.MethodGet, "/admin/api/setup", "", nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("GET setup status = %d, want %d", resp.Code, http.StatusOK)
|
||||
}
|
||||
if got := adminTestJSON(t, resp)["required"]; got != true {
|
||||
t.Fatalf("setup required = %v, want true", got)
|
||||
}
|
||||
|
||||
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/setup", "", map[string]any{
|
||||
"username": "admin",
|
||||
"password": "secret",
|
||||
})
|
||||
if resp.Code != http.StatusCreated {
|
||||
t.Fatalf("POST setup status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/setup", "", map[string]any{
|
||||
"username": "admin2",
|
||||
"password": "secret",
|
||||
})
|
||||
if resp.Code != http.StatusConflict {
|
||||
t.Fatalf("repeat setup status = %d, want %d", resp.Code, http.StatusConflict)
|
||||
}
|
||||
|
||||
adminToken := adminTestLogin(t, handler, "admin", "secret")
|
||||
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/status", adminToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("admin status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/users", adminToken, map[string]any{
|
||||
"username": "guest",
|
||||
"role": "guest",
|
||||
"password": "guest-secret",
|
||||
})
|
||||
if resp.Code != http.StatusCreated {
|
||||
t.Fatalf("create guest status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
guestToken := adminTestLogin(t, handler, "guest", "guest-secret")
|
||||
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/routes", guestToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("guest routes status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
resp = adminTestRequest(t, handler, http.MethodPut, "/admin/api/routes/play.example", guestToken, map[string]any{
|
||||
"upstream": "127.0.0.1:25565",
|
||||
"enabled": true,
|
||||
})
|
||||
if resp.Code != http.StatusForbidden {
|
||||
t.Fatalf("guest route write status = %d, want %d", resp.Code, http.StatusForbidden)
|
||||
}
|
||||
|
||||
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/users", guestToken, nil)
|
||||
if resp.Code != http.StatusForbidden {
|
||||
t.Fatalf("guest users status = %d, want %d", resp.Code, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRoutesRefreshSnapshot(t *testing.T) {
|
||||
handler := newAdminTestHandlerWithAdmin(t)
|
||||
token := adminTestLogin(t, handler, "admin", "secret")
|
||||
|
||||
resp := adminTestRequest(t, handler, http.MethodPut, "/admin/api/routes/play.example", token, map[string]any{
|
||||
"upstream": "127.0.0.1:25565",
|
||||
"enabled": true,
|
||||
"note": "primary",
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("route upsert status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
upstream, ok := lookupRoute("play.example")
|
||||
if !ok || upstream != "127.0.0.1:25565" {
|
||||
t.Fatalf("lookupRoute() = %q, %v; want route", upstream, ok)
|
||||
}
|
||||
|
||||
resp = adminTestRequest(t, handler, http.MethodDelete, "/admin/api/routes/play.example", token, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("route delete status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if upstream, ok := lookupRoute("play.example"); ok || upstream != "" {
|
||||
t.Fatalf("lookupRoute() after delete = %q, %v; want miss", upstream, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCustomPathAndAPIPrefixFromEnv(t *testing.T) {
|
||||
t.Cleanup(saveGatewayState(t))
|
||||
|
||||
t.Setenv(adminEnvDB, filepath.Join(t.TempDir(), "gateway.sqlite3"))
|
||||
t.Setenv(adminEnvPath, "/ops")
|
||||
t.Setenv(adminEnvAPIPrefix, "/ops/api")
|
||||
if err := loadConfig(); err != nil {
|
||||
t.Fatalf("loadConfig() error = %v", err)
|
||||
}
|
||||
handler := newGatewayHTTPHandler()
|
||||
|
||||
resp := adminTestRequest(t, handler, http.MethodGet, "/ops", "", nil)
|
||||
if resp.Code != http.StatusMovedPermanently {
|
||||
t.Fatalf("admin path redirect status = %d, want %d", resp.Code, http.StatusMovedPermanently)
|
||||
}
|
||||
if got := resp.Header().Get("Location"); got != "/ops/" {
|
||||
t.Fatalf("admin path redirect location = %q, want /ops/", got)
|
||||
}
|
||||
|
||||
resp = adminTestRequest(t, handler, http.MethodGet, "/ops/", "", nil)
|
||||
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(), `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)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("custom setup status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/setup", "", nil)
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("default setup status = %d, want %d", resp.Code, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminServiceUpdateMarksRestartRequired(t *testing.T) {
|
||||
handler := newAdminTestHandlerWithAdmin(t)
|
||||
token := adminTestLogin(t, handler, "admin", "secret")
|
||||
|
||||
resp := adminTestRequest(t, handler, http.MethodPut, "/admin/api/services/kcp", token, map[string]any{
|
||||
"enabled": true,
|
||||
"port": 25570,
|
||||
"options": map[string]any{
|
||||
"data_shards": 12,
|
||||
"parity_shards": 4,
|
||||
},
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("service update status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/services", token, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("services status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
body := adminTestJSON(t, resp)
|
||||
services := body["services"].([]any)
|
||||
var found map[string]any
|
||||
for _, item := range services {
|
||||
service := item.(map[string]any)
|
||||
if service["name"] == "kcp" {
|
||||
found = service
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatal("kcp service not found")
|
||||
}
|
||||
if found["enabled"] != true || found["restart_required"] != true || found["running"] != false {
|
||||
t.Fatalf("kcp service = %#v, want enabled restart_required and not running", found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUserPatchInvalidatesExistingSession(t *testing.T) {
|
||||
handler := newAdminTestHandlerWithAdmin(t)
|
||||
adminToken := adminTestLogin(t, handler, "admin", "secret")
|
||||
|
||||
resp := adminTestRequest(t, handler, http.MethodPost, "/admin/api/users", adminToken, map[string]any{
|
||||
"username": "member",
|
||||
"role": "member",
|
||||
"password": "member-secret",
|
||||
})
|
||||
if resp.Code != http.StatusCreated {
|
||||
t.Fatalf("create member status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
memberToken := adminTestLogin(t, handler, "member", "member-secret")
|
||||
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/status", memberToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("member status before patch = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
resp = adminTestRequest(t, handler, http.MethodPatch, "/admin/api/users/member", adminToken, map[string]any{
|
||||
"role": "guest",
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("patch member status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/status", memberToken, nil)
|
||||
if resp.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("old member token status = %d, want %d", resp.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPluginPhase4API(t *testing.T) {
|
||||
handler := newAdminTestHandlerWithAdmin(t)
|
||||
pluginsManager = pluginmanager.New(pluginmanager.Options{
|
||||
DB: adminDB,
|
||||
ArtifactRoot: filepath.Join(filepath.Dir(adminDBPath), "plugins", "artifacts"),
|
||||
Adapter: gatewayTestPluginAdapter{},
|
||||
})
|
||||
adminToken := adminTestLogin(t, handler, "admin", "secret")
|
||||
resp := adminTestRequest(t, handler, http.MethodPost, "/admin/api/users", adminToken, map[string]any{
|
||||
"username": "member",
|
||||
"role": "member",
|
||||
"password": "member-secret",
|
||||
})
|
||||
if resp.Code != http.StatusCreated {
|
||||
t.Fatalf("create member status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
memberToken := adminTestLogin(t, handler, "member", "member-secret")
|
||||
|
||||
artifact := uploadGatewayPhase4Artifact(t, "phase4-plugin")
|
||||
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "phase4-plugin", artifact.ID, pluginmanager.DesiredEnabled, `{"token":"old","host":"a"}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
|
||||
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/plugins", memberToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("member plugins list status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase4-plugin/secrets", memberToken, map[string]any{
|
||||
"name": "api_token",
|
||||
"value": "member-secret-value",
|
||||
})
|
||||
if resp.Code != http.StatusForbidden {
|
||||
t.Fatalf("member secret write status = %d, want forbidden; body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase4-plugin/secrets", adminToken, map[string]any{
|
||||
"artifact_id": artifact.ID,
|
||||
"name": "api_token",
|
||||
"value": "super-secret-value",
|
||||
"reload_required": true,
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("secret write status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if strings.Contains(resp.Body.String(), "super-secret-value") {
|
||||
t.Fatalf("secret response leaked value: %s", resp.Body.String())
|
||||
}
|
||||
|
||||
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase4-plugin/config/dry-run", adminToken, map[string]any{
|
||||
"artifact_id": artifact.ID,
|
||||
"config_json": `{"token":"new","host":"b"}`,
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("config dry-run status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if strings.Contains(resp.Body.String(), "new") || strings.Contains(resp.Body.String(), "old") {
|
||||
t.Fatalf("dry-run leaked sensitive value: %s", resp.Body.String())
|
||||
}
|
||||
|
||||
resp = adminTestRequest(t, handler, http.MethodPut, "/admin/api/plugins/phase4-plugin/config", adminToken, map[string]any{
|
||||
"artifact_id": artifact.ID,
|
||||
"config_json": `{"token":"new","host":"b"}`,
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("config update status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/plugins/phase4-plugin/config/snapshots", adminToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("snapshots status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
snapshotID := firstSnapshotID(t, resp)
|
||||
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/plugins/phase4-plugin/config/snapshots/"+strconv.FormatInt(snapshotID, 10)+"/diff", adminToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("snapshot diff status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if strings.Contains(resp.Body.String(), "new") || strings.Contains(resp.Body.String(), "old") {
|
||||
t.Fatalf("snapshot diff leaked sensitive value: %s", resp.Body.String())
|
||||
}
|
||||
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase4-plugin/rollback/config", adminToken, map[string]any{
|
||||
"snapshot_id": snapshotID,
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("config rollback status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/audit-logs", adminToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("audit status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if strings.Contains(resp.Body.String(), "super-secret-value") {
|
||||
t.Fatalf("audit leaked secret value: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPluginPhase5GovernanceAPI(t *testing.T) {
|
||||
handler := newAdminTestHandlerWithAdmin(t)
|
||||
pluginsManager = pluginmanager.New(pluginmanager.Options{
|
||||
DB: adminDB,
|
||||
ArtifactRoot: filepath.Join(filepath.Dir(adminDBPath), "plugins", "artifacts"),
|
||||
Adapter: gatewayTestPluginAdapter{},
|
||||
})
|
||||
adminToken := adminTestLogin(t, handler, "admin", "secret")
|
||||
resp := adminTestRequest(t, handler, http.MethodPost, "/admin/api/users", adminToken, map[string]any{
|
||||
"username": "member",
|
||||
"role": "member",
|
||||
"password": "member-secret",
|
||||
})
|
||||
if resp.Code != http.StatusCreated {
|
||||
t.Fatalf("create member status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
memberToken := adminTestLogin(t, handler, "member", "member-secret")
|
||||
|
||||
artifact := uploadGatewayPhase5ProtocolProxyArtifact(t, "phase5-proxy")
|
||||
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "phase5-proxy", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/plugins/phase5-proxy/governance?profile=prod", memberToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("member governance status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "review_required") {
|
||||
t.Fatalf("governance status body = %s, want review_required", resp.Body.String())
|
||||
}
|
||||
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase5-proxy/governance/review", memberToken, map[string]any{
|
||||
"artifact_id": artifact.ID,
|
||||
"profile": pluginmanager.PolicyProfileProd,
|
||||
})
|
||||
if resp.Code != http.StatusForbidden {
|
||||
t.Fatalf("member review write status = %d, want forbidden; body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase5-proxy/governance/review", adminToken, map[string]any{
|
||||
"artifact_id": artifact.ID,
|
||||
"profile": pluginmanager.PolicyProfileProd,
|
||||
"decision": pluginmanager.ReviewDecisionApproved,
|
||||
"notes": "phase 5 approval",
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("admin review write status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase5-proxy/governance/preflight", adminToken, map[string]any{
|
||||
"artifact_id": artifact.ID,
|
||||
"profile": pluginmanager.PolicyProfileProd,
|
||||
"action": pluginmanager.GovernanceActionEnable,
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("preflight status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase5-proxy/governance/benchmark", adminToken, map[string]any{
|
||||
"artifact_id": artifact.ID,
|
||||
"profile": pluginmanager.PolicyProfileProd,
|
||||
"benchmark_profile": "release",
|
||||
"p95_ms": 10,
|
||||
"p99_ms": 20,
|
||||
"baseline_diff": 0.25,
|
||||
"active_proxy_capacity": 100,
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("benchmark status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase5-proxy/governance/override", adminToken, map[string]any{
|
||||
"artifact_id": artifact.ID,
|
||||
"profile": pluginmanager.PolicyProfileProd,
|
||||
"action": pluginmanager.GovernanceActionEnable,
|
||||
"reason": "accepted warning for rollout",
|
||||
"ttl_seconds": 3600,
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("override status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugin-advisories", adminToken, map[string]any{
|
||||
"advisory_id": "MCG-2026-ADMIN",
|
||||
"status": pluginmanager.AdvisoryStatusRevoked,
|
||||
"action": pluginmanager.AdvisoryActionRevoke,
|
||||
"artifact_sha256": artifact.SHA256,
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("advisory status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/plugin-advisories?plugin_id=phase5-proxy", memberToken, nil)
|
||||
if resp.Code != http.StatusOK || !strings.Contains(resp.Body.String(), "MCG-2026-ADMIN") {
|
||||
t.Fatalf("member advisory read status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/audit-logs", adminToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("audit status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
for _, action := range []string{"plugin_governance_review", "plugin_governance_override", "plugin_governance_advisory"} {
|
||||
if !strings.Contains(resp.Body.String(), action) {
|
||||
t.Fatalf("audit body missing %s: %s", action, resp.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func uploadGatewayPhase4Artifact(t *testing.T, pluginID string) pluginmanager.ArtifactRecord {
|
||||
t.Helper()
|
||||
var manifest pluginmanager.Manifest
|
||||
if err := json.Unmarshal(gatewayTestManifest(t, pluginID), &manifest); err != nil {
|
||||
t.Fatalf("Unmarshal manifest error = %v", err)
|
||||
}
|
||||
manifest.ConfigSchema = json.RawMessage(`{"type":"object","properties":{"token":{"type":"string","sensitive":true},"host":{"type":"string"}}}`)
|
||||
manifest.Secrets = []pluginmanager.SecretSpec{{
|
||||
Name: "api_token",
|
||||
Required: false,
|
||||
Type: "api_token",
|
||||
Rotation: pluginmanager.SecretRotation{
|
||||
Reload: "reload_required",
|
||||
},
|
||||
}}
|
||||
manifestBytes, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal manifest error = %v", err)
|
||||
}
|
||||
artifact, err := pluginsManager.UploadArtifact(context.Background(), pluginmanager.ArtifactUpload{
|
||||
SourcePath: writeGatewayTestMCGPEntries(t, map[string][]byte{
|
||||
"manifest.json": manifestBytes,
|
||||
"plugin.so": []byte("fake plugin bytes " + pluginID),
|
||||
}),
|
||||
FileName: pluginID + ".mcgp",
|
||||
Actor: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UploadArtifact() error = %v", err)
|
||||
}
|
||||
return artifact
|
||||
}
|
||||
|
||||
func uploadGatewayPhase5ProtocolProxyArtifact(t *testing.T, pluginID string) pluginmanager.ArtifactRecord {
|
||||
t.Helper()
|
||||
var manifest pluginmanager.Manifest
|
||||
if err := json.Unmarshal(gatewayTestManifestWithCapabilities(t, pluginID, gatewayProtocolProxyCapabilities()), &manifest); err != nil {
|
||||
t.Fatalf("Unmarshal manifest error = %v", err)
|
||||
}
|
||||
manifest.RuntimeLimits = pluginmanager.RuntimeLimits{HandlerTimeoutMS: 3000}
|
||||
manifestBytes, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal manifest error = %v", err)
|
||||
}
|
||||
artifact, err := pluginsManager.UploadArtifact(context.Background(), pluginmanager.ArtifactUpload{
|
||||
SourcePath: writeGatewayTestMCGPEntries(t, map[string][]byte{
|
||||
"manifest.json": manifestBytes,
|
||||
"plugin.so": []byte("fake plugin bytes " + pluginID),
|
||||
}),
|
||||
FileName: pluginID + ".mcgp",
|
||||
Actor: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UploadArtifact() error = %v", err)
|
||||
}
|
||||
return artifact
|
||||
}
|
||||
|
||||
func newAdminTestHandler(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
t.Cleanup(saveGatewayState(t))
|
||||
|
||||
t.Setenv(adminEnvDB, filepath.Join(t.TempDir(), "gateway.sqlite3"))
|
||||
if err := loadConfig(); err != nil {
|
||||
t.Fatalf("loadConfig() error = %v", err)
|
||||
}
|
||||
return newGatewayHTTPHandler()
|
||||
}
|
||||
|
||||
func newAdminTestHandlerWithAdmin(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
handler := newAdminTestHandler(t)
|
||||
resp := adminTestRequest(t, handler, http.MethodPost, "/admin/api/setup", "", map[string]any{
|
||||
"username": "admin",
|
||||
"password": "secret",
|
||||
})
|
||||
if resp.Code != http.StatusCreated {
|
||||
t.Fatalf("setup status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
return handler
|
||||
}
|
||||
|
||||
func adminTestLogin(t *testing.T, handler http.Handler, username, password string) string {
|
||||
t.Helper()
|
||||
|
||||
resp := adminTestRequest(t, handler, http.MethodPost, "/admin/api/auth/login", "", map[string]any{
|
||||
"username": username,
|
||||
"password": password,
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("login status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
body := adminTestJSON(t, resp)
|
||||
token, ok := body["token"].(string)
|
||||
if !ok || token == "" {
|
||||
t.Fatalf("login token = %#v", body["token"])
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func adminTestRequest(t *testing.T, handler http.Handler, method, target, token string, body any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
var payload bytes.Buffer
|
||||
if body != nil {
|
||||
if err := json.NewEncoder(&payload).Encode(body); err != nil {
|
||||
t.Fatalf("Encode() error = %v", err)
|
||||
}
|
||||
}
|
||||
req := httptest.NewRequest(method, target, &payload)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp := httptest.NewRecorder()
|
||||
handler.ServeHTTP(resp, req)
|
||||
return resp
|
||||
}
|
||||
|
||||
func adminTestJSON(t *testing.T, resp *httptest.ResponseRecorder) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("Unmarshal(%q) error = %v", resp.Body.String(), err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func firstSnapshotID(t *testing.T, resp *httptest.ResponseRecorder) int64 {
|
||||
t.Helper()
|
||||
body := adminTestJSON(t, resp)
|
||||
snapshots, ok := body["snapshots"].([]any)
|
||||
if !ok || len(snapshots) == 0 {
|
||||
t.Fatalf("snapshots = %#v, want at least one", body["snapshots"])
|
||||
}
|
||||
first, ok := snapshots[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("first snapshot = %#v", snapshots[0])
|
||||
}
|
||||
id, ok := first["id"].(float64)
|
||||
if !ok || id <= 0 {
|
||||
t.Fatalf("snapshot id = %#v", first["id"])
|
||||
}
|
||||
return int64(id)
|
||||
}
|
||||
21
cmd/gateway/admin_audit.go
Normal file
21
cmd/gateway/admin_audit.go
Normal file
@@ -0,0 +1,21 @@
|
||||
// cmd/gateway/admin_audit.go 把 HTTP 请求上下文转换为持久化审计记录,用于追踪管理端变更。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tursom/mc-gateway/internal/adminaudit"
|
||||
)
|
||||
|
||||
func recordAudit(ctx context.Context, actor, sourceIP, action, targetType, targetID string, success bool, message string) {
|
||||
_ = 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)
|
||||
}
|
||||
115
cmd/gateway/admin_auth_handlers.go
Normal file
115
cmd/gateway/admin_auth_handlers.go
Normal file
@@ -0,0 +1,115 @@
|
||||
// cmd/gateway/admin_auth_handlers.go 处理初始化、登录、登出和当前会话查询等嵌入式管理端认证接口。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tursom/mc-gateway/internal/adminhttp"
|
||||
"github.com/tursom/mc-gateway/internal/adminuser"
|
||||
)
|
||||
|
||||
func handleAdminSetupStatus(w http.ResponseWriter, r *http.Request) {
|
||||
empty, err := usersTableEmpty(r.Context(), adminDB)
|
||||
if err != nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"required": empty})
|
||||
}
|
||||
|
||||
func handleAdminSetup(w http.ResponseWriter, r *http.Request) {
|
||||
var req adminhttp.SetupRequest
|
||||
if !adminhttp.DecodeJSONRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Username) == "" {
|
||||
req.Username = "admin"
|
||||
}
|
||||
|
||||
err := createInitialAdmin(r.Context(), req.Username, req.Password)
|
||||
if err != nil {
|
||||
recordAudit(r.Context(), "setup", adminhttp.RequestSourceIP(r), "setup", "user", req.Username, false, err.Error())
|
||||
status := http.StatusBadRequest
|
||||
if strings.Contains(err.Error(), "already") {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
adminhttp.WriteAPIError(w, status, err.Error())
|
||||
return
|
||||
}
|
||||
recordAudit(r.Context(), "setup", adminhttp.RequestSourceIP(r), "setup", "user", req.Username, true, "created initial admin")
|
||||
adminhttp.WriteJSON(w, http.StatusCreated, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func handleAdminLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var req adminhttp.LoginRequest
|
||||
if !adminhttp.DecodeJSONRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
user, err := authenticateUser(r.Context(), req.Username, req.Password)
|
||||
if err != nil {
|
||||
recordAudit(r.Context(), req.Username, adminhttp.RequestSourceIP(r), "login", "user", req.Username, false, err.Error())
|
||||
adminhttp.WriteAPIError(w, http.StatusUnauthorized, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
session, err := createSession(user.Username, user.Role)
|
||||
if err != nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
recordAudit(r.Context(), user.Username, adminhttp.RequestSourceIP(r), "login", "user", user.Username, true, "login success")
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"token": session.Token,
|
||||
"expires_at": session.ExpiresAt.Unix(),
|
||||
"user": user,
|
||||
})
|
||||
}
|
||||
|
||||
func handleAdminLogout(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := requireSession(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
deleteSession(session.Token)
|
||||
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "logout", "session", session.Username, true, "logout success")
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func handleAdminMe(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := requireSession(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"username": session.Username,
|
||||
"role": session.Role,
|
||||
"expires_at": session.ExpiresAt.Unix(),
|
||||
"permissions": adminuser.Permissions(session.Role),
|
||||
})
|
||||
}
|
||||
|
||||
func handleAdminStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireRole(w, r, adminRoleMember); !ok {
|
||||
return
|
||||
}
|
||||
services, err := listServiceConfigs(r.Context(), adminDB)
|
||||
if err != nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"pid": os.Getpid(),
|
||||
"uptime_seconds": int64(time.Since(processStartAt).Seconds()),
|
||||
"db_path": adminDBPath,
|
||||
"tcp_admin_port": adminStartup.TCPAdminPort,
|
||||
"admin_path": adminStartup.AdminPath,
|
||||
"admin_api_prefix": adminStartup.AdminAPIPrefix,
|
||||
"services": services,
|
||||
})
|
||||
}
|
||||
10
cmd/gateway/admin_frontend/src/alerts.ts
Normal file
10
cmd/gateway/admin_frontend/src/alerts.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// cmd/gateway/admin_frontend/src/alerts.ts 集中处理告警展示,让异步界面流程可以一致地清空或显示错误。
|
||||
|
||||
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);
|
||||
}
|
||||
49
cmd/gateway/admin_frontend/src/api.ts
Normal file
49
cmd/gateway/admin_frontend/src/api.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// cmd/gateway/admin_frontend/src/api.ts 封装 fetch,统一处理 Admin API 前缀、令牌、JSON 编码和错误返回。
|
||||
|
||||
import { state } from "./state.js";
|
||||
|
||||
interface APIOptions {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
formData?: FormData;
|
||||
}
|
||||
|
||||
export async function api<T>(path: string, options: APIOptions = {}): Promise<T> {
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
if (options.body !== undefined && !options.formData) {
|
||||
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.formData || (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;
|
||||
}
|
||||
15
cmd/gateway/admin_frontend/src/config.ts
Normal file
15
cmd/gateway/admin_frontend/src/config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// cmd/gateway/admin_frontend/src/config.ts 读取嵌入式管理端 HTML 壳注入的运行时配置。
|
||||
|
||||
import type { RuntimeConfig } from "./types.js";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
MCGatewayAdmin?: Partial<RuntimeConfig>;
|
||||
}
|
||||
}
|
||||
|
||||
export function runtimeConfig(): RuntimeConfig {
|
||||
return {
|
||||
apiPrefix: window.MCGatewayAdmin?.apiPrefix || "/admin/api",
|
||||
};
|
||||
}
|
||||
55
cmd/gateway/admin_frontend/src/dom.ts
Normal file
55
cmd/gateway/admin_frontend/src/dom.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// cmd/gateway/admin_frontend/src/dom.ts 集中 DOM 辅助方法,包括查询、转义、徽标、去抖和表单取值。
|
||||
|
||||
export function el<T extends HTMLElement = HTMLElement>(id: string): T {
|
||||
const node = document.getElementById(id);
|
||||
if (!node) {
|
||||
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;
|
||||
}
|
||||
323
cmd/gateway/admin_frontend/src/i18n.ts
Normal file
323
cmd/gateway/admin_frontend/src/i18n.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
// cmd/gateway/admin_frontend/src/i18n.ts 保存嵌入式管理端翻译字典,并提供语言切换辅助方法。
|
||||
|
||||
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",
|
||||
active: "Active",
|
||||
adminSubtitle: "Admin",
|
||||
artifact: "Artifact",
|
||||
audit: "Audit",
|
||||
cancel: "Cancel",
|
||||
createAdmin: "Create admin",
|
||||
dataShards: "Data shards",
|
||||
delete: "Delete",
|
||||
deleteDefaultRouteConfirm: "Delete default route?",
|
||||
deleteUserConfirm: "Delete user {username}?",
|
||||
desired: "Desired",
|
||||
desiredArtifact: "Desired artifact",
|
||||
dialErrors: "Dial errors",
|
||||
disabled: "Disabled",
|
||||
edit: "Edit",
|
||||
enabled: "Enabled",
|
||||
extensions: "Extensions",
|
||||
failed: "Failed",
|
||||
health: "Health",
|
||||
host: "Host",
|
||||
initialAdmin: "Initial admin",
|
||||
language: "Language",
|
||||
languageChinese: "中文",
|
||||
languageEnglish: "English",
|
||||
login: "Login",
|
||||
logout: "Logout",
|
||||
message: "Message",
|
||||
metrics: "Metrics",
|
||||
misses: "Misses",
|
||||
name: "Name",
|
||||
newRoute: "New route",
|
||||
newUser: "New user",
|
||||
noHits: "No hits",
|
||||
note: "Note",
|
||||
parityShards: "Parity shards",
|
||||
password: "Password",
|
||||
path: "Path",
|
||||
port: "Port",
|
||||
protocols: "Protocols",
|
||||
pluginID: "Plugin ID",
|
||||
plugins: "Plugins",
|
||||
priority: "Priority",
|
||||
refresh: "Refresh",
|
||||
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",
|
||||
runtime: "Runtime",
|
||||
save: "Save",
|
||||
services: "Services",
|
||||
setupSubtitle: "Setup",
|
||||
success: "Success",
|
||||
target: "Target",
|
||||
time: "Time",
|
||||
total: "Total",
|
||||
upstream: "Upstream",
|
||||
uploadPlugin: "Upload plugin",
|
||||
uptime: "Uptime",
|
||||
user: "User",
|
||||
username: "Username",
|
||||
users: "Users",
|
||||
version: "Version",
|
||||
},
|
||||
zh: {
|
||||
activeConnections: "活跃连接",
|
||||
activeUser: "启用",
|
||||
actions: "操作",
|
||||
actor: "操作者",
|
||||
active: "当前",
|
||||
adminSubtitle: "管理后台",
|
||||
artifact: "Artifact",
|
||||
audit: "审计",
|
||||
cancel: "取消",
|
||||
createAdmin: "创建管理员",
|
||||
dataShards: "数据分片",
|
||||
delete: "删除",
|
||||
deleteDefaultRouteConfirm: "确认删除默认路由?",
|
||||
deleteUserConfirm: "确认删除用户 {username}?",
|
||||
desired: "期望",
|
||||
desiredArtifact: "期望 Artifact",
|
||||
dialErrors: "连接上游失败",
|
||||
disabled: "禁用",
|
||||
edit: "编辑",
|
||||
enabled: "启用",
|
||||
extensions: "扩展点",
|
||||
failed: "失败",
|
||||
health: "健康",
|
||||
host: "主机",
|
||||
initialAdmin: "初始化管理员",
|
||||
language: "语言",
|
||||
languageChinese: "中文",
|
||||
languageEnglish: "English",
|
||||
login: "登录",
|
||||
logout: "退出登录",
|
||||
message: "消息",
|
||||
metrics: "指标",
|
||||
misses: "未命中",
|
||||
name: "名称",
|
||||
newRoute: "新建路由",
|
||||
newUser: "新建用户",
|
||||
noHits: "暂无命中",
|
||||
note: "备注",
|
||||
parityShards: "校验分片",
|
||||
password: "密码",
|
||||
path: "路径",
|
||||
port: "端口",
|
||||
protocols: "协议",
|
||||
pluginID: "插件 ID",
|
||||
plugins: "插件",
|
||||
priority: "优先级",
|
||||
refresh: "刷新",
|
||||
restart: "重启",
|
||||
restartRequired: "需要重启",
|
||||
result: "结果",
|
||||
role: "角色",
|
||||
roleAdmin: "管理员",
|
||||
roleGuest: "访客",
|
||||
roleMember: "成员",
|
||||
route: "路由",
|
||||
routeHits: "路由命中",
|
||||
routeSearch: "搜索主机、上游、备注",
|
||||
routes: "路由",
|
||||
runtime: "运行时",
|
||||
save: "保存",
|
||||
services: "服务",
|
||||
setupSubtitle: "初始化",
|
||||
success: "成功",
|
||||
target: "目标",
|
||||
time: "时间",
|
||||
total: "总数",
|
||||
upstream: "上游",
|
||||
uploadPlugin: "上传插件",
|
||||
uptime: "运行时间",
|
||||
user: "用户",
|
||||
username: "用户名",
|
||||
users: "用户",
|
||||
version: "版本",
|
||||
},
|
||||
} 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;
|
||||
}
|
||||
210
cmd/gateway/admin_frontend/src/main.ts
Normal file
210
cmd/gateway/admin_frontend/src/main.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
// cmd/gateway/admin_frontend/src/main.ts 启动嵌入式管理端,选择初始化/登录/应用视图,并协调按角色加载数据。
|
||||
|
||||
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 { bindPluginEvents, loadPlugins, renderPluginDetail, renderPlugins } from "./views/plugins.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> {
|
||||
// API 前缀由后端嵌入到 HTML 中,前端启动时先读取它,避免部署在子路径时写死地址。
|
||||
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) {
|
||||
// token 保存在本地状态中;没有 token 时直接进入登录视图。
|
||||
setView("loginView");
|
||||
setSubtitle("login");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
state.user = await api<User>("/me");
|
||||
await showApp();
|
||||
} catch {
|
||||
// token 失效时清空本地状态,避免后续 API 调用持续带着过期凭证。
|
||||
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);
|
||||
bindPluginEvents();
|
||||
|
||||
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 {
|
||||
// 初始化只创建首个管理员账号,创建成功后仍要求用户走登录流程获取会话 token。
|
||||
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 {
|
||||
// 登录成功后立即保存 token 和用户信息,再统一进入应用态加载流程。
|
||||
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();
|
||||
await loadPlugins();
|
||||
}
|
||||
if (isAdmin()) {
|
||||
// 管理员专属数据放在最后加载,减少普通成员的无权限请求。
|
||||
await loadUsers();
|
||||
await loadAudit();
|
||||
}
|
||||
}
|
||||
|
||||
function applyRoleVisibility(): void {
|
||||
const member = isMember();
|
||||
const admin = isAdmin();
|
||||
// 角色控制只隐藏入口;服务端仍会按 token 做权限校验。
|
||||
el("statusGrid").classList.toggle("hidden", !member);
|
||||
el("newRouteBtn").classList.toggle("hidden", !member);
|
||||
toggleTab("services", member);
|
||||
toggleTab("plugins", 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();
|
||||
renderPlugins();
|
||||
renderPluginDetail();
|
||||
if (isMember()) {
|
||||
loadStatus();
|
||||
loadMetrics();
|
||||
loadPlugins();
|
||||
}
|
||||
if (isAdmin()) {
|
||||
loadAudit();
|
||||
}
|
||||
}
|
||||
|
||||
boot();
|
||||
28
cmd/gateway/admin_frontend/src/session.ts
Normal file
28
cmd/gateway/admin_frontend/src/session.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// cmd/gateway/admin_frontend/src/session.ts 渲染会话用户状态,并提供管理端界面使用的角色判断。
|
||||
|
||||
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;
|
||||
}
|
||||
49
cmd/gateway/admin_frontend/src/state.ts
Normal file
49
cmd/gateway/admin_frontend/src/state.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// cmd/gateway/admin_frontend/src/state.ts 保存各视图模块共享的可变客户端状态。
|
||||
|
||||
import type { PluginArtifact, PluginBuild, PluginInstrumentation, PluginServiceStatus, PluginView, 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[];
|
||||
plugins: PluginView[];
|
||||
pluginArtifacts: PluginArtifact[];
|
||||
pluginBuilds: PluginBuild[];
|
||||
pluginService: PluginServiceStatus | null;
|
||||
pluginInstrumentation: PluginInstrumentation[];
|
||||
selectedPluginID: string;
|
||||
selectedArtifactID: string;
|
||||
}
|
||||
|
||||
export const state: AppState = {
|
||||
apiBase: "/admin/api",
|
||||
language: "",
|
||||
token: sessionStorage.getItem(tokenStorageKey) || "",
|
||||
user: null,
|
||||
routes: [],
|
||||
services: [],
|
||||
users: [],
|
||||
plugins: [],
|
||||
pluginArtifacts: [],
|
||||
pluginBuilds: [],
|
||||
pluginService: null,
|
||||
pluginInstrumentation: [],
|
||||
selectedPluginID: "",
|
||||
selectedArtifactID: "",
|
||||
};
|
||||
|
||||
export function setToken(token: string): void {
|
||||
state.token = token;
|
||||
if (token) {
|
||||
sessionStorage.setItem(tokenStorageKey, token);
|
||||
} else {
|
||||
sessionStorage.removeItem(tokenStorageKey);
|
||||
}
|
||||
}
|
||||
272
cmd/gateway/admin_frontend/src/types.ts
Normal file
272
cmd/gateway/admin_frontend/src/types.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
// cmd/gateway/admin_frontend/src/types.ts 声明 Admin API 返回并被各视图消费的 TypeScript 数据结构。
|
||||
|
||||
export type Role = "admin" | "member" | "guest";
|
||||
|
||||
export interface User {
|
||||
username: string;
|
||||
role: Role;
|
||||
disabled: boolean;
|
||||
permissions?: Record<string, 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 PluginArtifact {
|
||||
id: string;
|
||||
plugin_id: string;
|
||||
version: string;
|
||||
file_name?: string;
|
||||
sha256: string;
|
||||
package_sha256?: string;
|
||||
artifact_type: string;
|
||||
runtime_type: string;
|
||||
status: string;
|
||||
metadata_json?: string;
|
||||
capabilities_summary_json?: string;
|
||||
extension_points_json?: string;
|
||||
api_version?: string;
|
||||
go_version?: string;
|
||||
go_os?: string;
|
||||
go_arch?: string;
|
||||
uploaded_by?: string;
|
||||
error?: string;
|
||||
created_at?: number;
|
||||
updated_at?: number;
|
||||
}
|
||||
|
||||
export interface PluginBuild {
|
||||
id: number;
|
||||
plugin_id: string;
|
||||
source_id: string;
|
||||
artifact_id: string;
|
||||
status: string;
|
||||
builder_type: string;
|
||||
go_version?: string;
|
||||
go_os?: string;
|
||||
go_arch?: string;
|
||||
source_sha256?: string;
|
||||
artifact_sha256?: string;
|
||||
log_summary?: string;
|
||||
error?: string;
|
||||
duration_ms?: number;
|
||||
created_at?: number;
|
||||
updated_at?: number;
|
||||
}
|
||||
|
||||
export interface PluginSecret {
|
||||
plugin_id: string;
|
||||
name: string;
|
||||
current_version: number;
|
||||
previous_version: number;
|
||||
reload_required: boolean;
|
||||
hot_reload: boolean;
|
||||
updated_by?: string;
|
||||
updated_at?: number;
|
||||
}
|
||||
|
||||
export interface PluginSnapshot {
|
||||
id: number;
|
||||
plugin_id: string;
|
||||
artifact_id: string;
|
||||
config_json: string;
|
||||
desired_state: string;
|
||||
priority: number;
|
||||
desired_generation: number;
|
||||
created_by?: string;
|
||||
created_at?: number;
|
||||
}
|
||||
|
||||
export interface PluginProxyConnection {
|
||||
id: number;
|
||||
plugin_id: string;
|
||||
artifact_id: string;
|
||||
handler_id: string;
|
||||
started_at: number;
|
||||
duration_ms: number;
|
||||
draining: boolean;
|
||||
}
|
||||
|
||||
export interface PluginServiceState {
|
||||
desired_mode: string;
|
||||
active_mode: string;
|
||||
applied_at?: number;
|
||||
restart_required: boolean;
|
||||
live_migration?: string;
|
||||
last_error?: string;
|
||||
updated_by?: string;
|
||||
updated_at?: number;
|
||||
}
|
||||
|
||||
export interface PluginHostRuntimeSummary {
|
||||
plugin_id: string;
|
||||
artifact_id: string;
|
||||
state: string;
|
||||
drain_mode: string;
|
||||
crash_loop: boolean;
|
||||
crash_count: number;
|
||||
last_error?: string;
|
||||
}
|
||||
|
||||
export interface PluginServiceStatus {
|
||||
service: PluginServiceState;
|
||||
hosts?: PluginHostRuntimeSummary[];
|
||||
}
|
||||
|
||||
export interface PluginInstrumentation {
|
||||
id: number;
|
||||
name: string;
|
||||
version: string;
|
||||
profile: string;
|
||||
generated_diff_hash: string;
|
||||
runbook_rollback: string;
|
||||
status: string;
|
||||
created_by?: string;
|
||||
created_at?: number;
|
||||
}
|
||||
|
||||
export interface GovernanceIssue {
|
||||
code: string;
|
||||
severity: string;
|
||||
message: string;
|
||||
plugin_id?: string;
|
||||
artifact_id?: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GovernanceDecision {
|
||||
ok: boolean;
|
||||
action: string;
|
||||
profile: string;
|
||||
risk_level: string;
|
||||
policy_hash: string;
|
||||
review_required: boolean;
|
||||
warning_override_used: boolean;
|
||||
issues?: GovernanceIssue[];
|
||||
checks?: GovernanceIssue[];
|
||||
}
|
||||
|
||||
export interface GovernanceStatus {
|
||||
decision?: GovernanceDecision;
|
||||
policy?: Record<string, unknown>;
|
||||
reviews?: Record<string, unknown>[];
|
||||
warning_overrides?: Record<string, unknown>[];
|
||||
preflights?: Record<string, unknown>[];
|
||||
benchmarks?: Record<string, unknown>[];
|
||||
advisories?: Record<string, unknown>[];
|
||||
conflicts?: {
|
||||
ok: boolean;
|
||||
issues?: GovernanceIssue[];
|
||||
plan?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PluginView {
|
||||
id: string;
|
||||
name?: string;
|
||||
version?: string;
|
||||
artifact_type?: string;
|
||||
runtime_type?: string;
|
||||
desired_state: string;
|
||||
runtime_state: string;
|
||||
desired_artifact_id: string;
|
||||
active_artifact_id: string;
|
||||
loaded_artifact_id: string;
|
||||
desired_artifact?: PluginArtifact;
|
||||
active_artifact?: PluginArtifact;
|
||||
loaded_artifact?: PluginArtifact;
|
||||
extension_points?: string[];
|
||||
priority: number;
|
||||
scope?: unknown;
|
||||
rollout?: unknown;
|
||||
restart_required?: boolean;
|
||||
health?: string;
|
||||
last_error?: string;
|
||||
runtime_summary?: Record<string, unknown>;
|
||||
dispatch_summary?: unknown[];
|
||||
extension_status?: Record<string, unknown>;
|
||||
capabilities_summary?: Record<string, unknown>;
|
||||
minecraft?: unknown;
|
||||
config_json?: string;
|
||||
config_schema?: Record<string, unknown>;
|
||||
secrets?: PluginSecret[];
|
||||
snapshots?: PluginSnapshot[];
|
||||
builds?: PluginBuild[];
|
||||
artifacts?: PluginArtifact[];
|
||||
manifest?: Record<string, unknown>;
|
||||
active_proxy_connections?: number;
|
||||
proxy_connections?: PluginProxyConnection[];
|
||||
governance?: GovernanceStatus;
|
||||
governance_error?: string;
|
||||
updated_at?: number;
|
||||
}
|
||||
|
||||
export interface PluginOperations {
|
||||
handlers?: Record<string, unknown>[];
|
||||
builds?: Record<string, unknown>[];
|
||||
events?: Record<string, unknown>[];
|
||||
custom_metrics?: Record<string, unknown>[];
|
||||
logs?: Record<string, unknown>[];
|
||||
traces?: Record<string, unknown>[];
|
||||
background_tasks?: Record<string, unknown>[];
|
||||
plugin_data?: Record<string, unknown>[];
|
||||
plugin_files?: Record<string, unknown>[];
|
||||
external_dependencies?: Record<string, unknown>[];
|
||||
gc?: Record<string, unknown>[];
|
||||
event_queue?: Record<string, unknown>;
|
||||
diagnostics?: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
export interface PluginDryRunResult {
|
||||
ok: boolean;
|
||||
restart_required: boolean;
|
||||
hot_reload: boolean;
|
||||
sensitive_paths: string[];
|
||||
redacted_config_json: string;
|
||||
redacted_diff_json: string;
|
||||
}
|
||||
|
||||
export interface SetupStatus {
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface RuntimeConfig {
|
||||
apiPrefix: string;
|
||||
}
|
||||
29
cmd/gateway/admin_frontend/src/views/audit.ts
Normal file
29
cmd/gateway/admin_frontend/src/views/audit.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// cmd/gateway/admin_frontend/src/views/audit.ts 渲染管理员用于复核运行态变更的审计日志。
|
||||
|
||||
import { api } from "../api.js";
|
||||
import { showAlert } from "../alerts.js";
|
||||
import { badge, el, escapeHTML } from "../dom.js";
|
||||
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);
|
||||
}
|
||||
}
|
||||
27
cmd/gateway/admin_frontend/src/views/metrics.ts
Normal file
27
cmd/gateway/admin_frontend/src/views/metrics.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// cmd/gateway/admin_frontend/src/views/metrics.ts 渲染管理端成员可见的网关指标计数器。
|
||||
|
||||
import { api } from "../api.js";
|
||||
import { showAlert } from "../alerts.js";
|
||||
import { el, escapeHTML, stat } from "../dom.js";
|
||||
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);
|
||||
}
|
||||
}
|
||||
994
cmd/gateway/admin_frontend/src/views/plugins.ts
Normal file
994
cmd/gateway/admin_frontend/src/views/plugins.ts
Normal file
@@ -0,0 +1,994 @@
|
||||
// cmd/gateway/admin_frontend/src/views/plugins.ts 渲染插件清单、插件详情、配置/密钥/治理动作和运维工具。
|
||||
|
||||
import { api } from "../api.js";
|
||||
import { showAlert } from "../alerts.js";
|
||||
import { badge, el, escapeAttr, escapeHTML, getFormInput } from "../dom.js";
|
||||
import { isAdmin } from "../session.js";
|
||||
import { state } from "../state.js";
|
||||
import type { PluginArtifact, PluginBuild, PluginDryRunResult, PluginInstrumentation, PluginOperations, PluginProxyConnection, PluginSecret, PluginServiceStatus, PluginSnapshot, PluginView } from "../types.js";
|
||||
|
||||
interface PluginsResponse {
|
||||
plugins?: PluginView[];
|
||||
}
|
||||
|
||||
interface ArtifactsResponse {
|
||||
artifacts?: PluginArtifact[];
|
||||
}
|
||||
|
||||
interface BuildsResponse {
|
||||
builds?: PluginBuild[];
|
||||
}
|
||||
|
||||
interface PluginServiceResponse {
|
||||
plugin_service?: PluginServiceStatus;
|
||||
}
|
||||
|
||||
interface InstrumentationResponse {
|
||||
instrumentation?: PluginInstrumentation[];
|
||||
}
|
||||
|
||||
interface PluginResponse {
|
||||
plugin?: PluginView;
|
||||
}
|
||||
|
||||
interface DryRunResponse {
|
||||
result?: PluginDryRunResult;
|
||||
}
|
||||
|
||||
interface SnapshotDiffResponse {
|
||||
diff?: {
|
||||
redacted_diff_json?: string;
|
||||
sensitive_paths?: string[];
|
||||
restart_required?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface OperationsResponse {
|
||||
operations?: PluginOperations;
|
||||
candidates?: Record<string, unknown>[];
|
||||
diagnostic?: Record<string, unknown>;
|
||||
summary?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function loadPlugins(): Promise<void> {
|
||||
try {
|
||||
// 插件页首屏依赖插件记录、制品、构建、插件服务模式和观测数据;
|
||||
// 并行请求可以减少进入页面时的等待时间。
|
||||
const [data, artifacts, builds, service, instrumentation] = await Promise.all([
|
||||
api<PluginsResponse>("/plugins"),
|
||||
api<ArtifactsResponse>("/plugin-artifacts"),
|
||||
api<BuildsResponse>("/plugin-builds"),
|
||||
api<PluginServiceResponse>("/plugin-service"),
|
||||
api<InstrumentationResponse>("/plugin-instrumentation"),
|
||||
]);
|
||||
state.plugins = data.plugins || [];
|
||||
state.pluginArtifacts = artifacts.artifacts || [];
|
||||
state.pluginBuilds = builds.builds || [];
|
||||
state.pluginService = service.plugin_service || null;
|
||||
state.pluginInstrumentation = instrumentation.instrumentation || [];
|
||||
const firstPlugin = state.plugins[0];
|
||||
if (!state.selectedPluginID && firstPlugin) {
|
||||
// 初次进入时默认选中第一个已纳管插件;未纳管制品会在列表中单独展示。
|
||||
state.selectedPluginID = firstPlugin.id;
|
||||
}
|
||||
renderPlugins();
|
||||
if (state.selectedPluginID) {
|
||||
await loadPluginDetail(state.selectedPluginID);
|
||||
} else {
|
||||
renderPluginDetail(null);
|
||||
}
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderPlugins(): void {
|
||||
const managed = new Set(state.plugins.map((plugin) => plugin.id));
|
||||
// 未纳管制品还没有 plugins 表记录,但仍要展示,方便管理员创建期望状态。
|
||||
const unmanagedArtifacts = state.pluginArtifacts.filter((artifact) => !managed.has(artifact.plugin_id));
|
||||
renderPluginServicePanel();
|
||||
el("pluginsBody").innerHTML = state.plugins.map((plugin) => `
|
||||
<tr class="${plugin.id === state.selectedPluginID ? "selected" : ""}">
|
||||
<td><button class="link-button" type="button" data-plugin-detail="${escapeAttr(plugin.id)}">${escapeHTML(plugin.id)}</button></td>
|
||||
<td>${escapeHTML(plugin.name || "")}</td>
|
||||
<td>${escapeHTML(plugin.version || "")}</td>
|
||||
<td>${escapeHTML(plugin.artifact_type || "")}</td>
|
||||
<td>${escapeHTML(plugin.runtime_type || "")}</td>
|
||||
<td>${escapeHTML(plugin.desired_state)}</td>
|
||||
<td>${badge(plugin.runtime_state, plugin.runtime_state !== "enabled")}</td>
|
||||
<td>${escapeHTML(shortID(plugin.desired_artifact_id))}</td>
|
||||
<td>${escapeHTML((plugin.extension_points || []).join(", "))}</td>
|
||||
<td>${escapeHTML(String(plugin.priority))}</td>
|
||||
<td>${badge(plugin.restart_required ? "yes" : "no", !plugin.restart_required)}</td>
|
||||
<td>${badge(plugin.health || "", plugin.health !== "healthy")}</td>
|
||||
</tr>
|
||||
`).concat(unmanagedArtifacts.map((artifact) => `
|
||||
<tr class="${artifact.id === state.selectedArtifactID ? "selected" : ""}">
|
||||
<td><button class="link-button" type="button" data-artifact-detail="${escapeAttr(artifact.id)}">${escapeHTML(artifact.plugin_id)}</button></td>
|
||||
<td>${escapeHTML(artifact.file_name || "")}</td>
|
||||
<td>${escapeHTML(artifact.version || "")}</td>
|
||||
<td>${escapeHTML(artifact.artifact_type)}</td>
|
||||
<td>${escapeHTML(artifact.runtime_type || "")}</td>
|
||||
<td>${escapeHTML(artifact.status)}</td>
|
||||
<td>${badge("not managed", true)}</td>
|
||||
<td>${escapeHTML(shortID(artifact.id))}</td>
|
||||
<td>${escapeHTML(jsonList(artifact.extension_points_json))}</td>
|
||||
<td></td>
|
||||
<td>${badge("no", false)}</td>
|
||||
<td>${badge(artifact.error ? "error" : "pending", Boolean(artifact.error))}</td>
|
||||
</tr>
|
||||
`)).join("");
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-plugin-detail]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const id = button.dataset.pluginDetail;
|
||||
if (id) {
|
||||
state.selectedPluginID = id;
|
||||
state.selectedArtifactID = "";
|
||||
loadPluginDetail(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-artifact-detail]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
state.selectedArtifactID = button.dataset.artifactDetail || "";
|
||||
state.selectedPluginID = "";
|
||||
renderPlugins();
|
||||
renderPluginDetail(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadPluginDetail(pluginID: string): Promise<void> {
|
||||
try {
|
||||
const data = await api<PluginResponse>(`/plugins/${encodeURIComponent(pluginID)}`);
|
||||
if (data.plugin) {
|
||||
// 详情接口返回完整插件视图,用它回填列表中的摘要记录。
|
||||
state.plugins = state.plugins.map((plugin) => plugin.id === data.plugin?.id ? data.plugin : plugin);
|
||||
if (!state.plugins.some((plugin) => plugin.id === data.plugin?.id)) {
|
||||
state.plugins.push(data.plugin);
|
||||
}
|
||||
state.selectedPluginID = data.plugin.id;
|
||||
renderPlugins();
|
||||
renderPluginDetail(data.plugin);
|
||||
}
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderPluginDetail(plugin: PluginView | null = selectedPlugin()): void {
|
||||
const detail = el("pluginDetail");
|
||||
if (!plugin) {
|
||||
// 没有选中纳管插件时展示制品库存和源码构建入口。
|
||||
detail.innerHTML = uploadInventoryDetail();
|
||||
bindInventoryEvents();
|
||||
return;
|
||||
}
|
||||
const canWrite = isAdmin();
|
||||
// 插件详情拆成多个小面板,避免配置、治理、构建和运维信息混成一个长表格。
|
||||
detail.innerHTML = `
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
<h2>${escapeHTML(plugin.name || plugin.id)}</h2>
|
||||
<p>${escapeHTML(plugin.id)} · ${escapeHTML(plugin.version || "")}</p>
|
||||
</div>
|
||||
<div class="row-actions">${canWrite ? pluginActionButtons(plugin) : ""}</div>
|
||||
</div>
|
||||
<div class="status-grid dense">
|
||||
${detailStat("Runtime", plugin.runtime_state)}
|
||||
${detailStat("Desired", plugin.desired_state)}
|
||||
${detailStat("Active", shortID(plugin.active_artifact_id))}
|
||||
${detailStat("Desired artifact", shortID(plugin.desired_artifact_id))}
|
||||
${detailStat("Loaded", shortID(plugin.loaded_artifact_id))}
|
||||
${detailStat("Restart", plugin.restart_required ? "required" : "not required")}
|
||||
${detailStat("Health", plugin.health || "")}
|
||||
${detailStat("Active proxy", plugin.active_proxy_connections || 0)}
|
||||
</div>
|
||||
${plugin.last_error ? `<div class="alert inline-alert">${escapeHTML(plugin.last_error)}</div>` : ""}
|
||||
<div class="plugin-layout">
|
||||
<section class="panel">
|
||||
<h3>Manifest</h3>
|
||||
<dl class="kv">
|
||||
<dt>Runtime</dt><dd>${escapeHTML(plugin.runtime_type || "")}</dd>
|
||||
<dt>Extensions</dt><dd>${escapeHTML((plugin.extension_points || []).join(", "))}</dd>
|
||||
<dt>Scope</dt><dd>${escapeHTML(formatJSON(plugin.scope))}</dd>
|
||||
<dt>Rollout</dt><dd>${escapeHTML(formatJSON(plugin.rollout))}</dd>
|
||||
<dt>Minecraft</dt><dd>${escapeHTML(formatJSON(plugin.minecraft))}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h3>Governance</h3>
|
||||
${governancePanel(plugin, canWrite)}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h3>Config</h3>
|
||||
<textarea id="pluginConfigEditor" ${canWrite ? "" : "readonly"}>${escapeHTML(prettyJSON(plugin.config_json || "{}"))}</textarea>
|
||||
<div class="row-actions">${canWrite ? `
|
||||
<button type="button" id="pluginDryRunBtn">Dry run</button>
|
||||
<button type="button" id="pluginSaveConfigBtn">Save config</button>
|
||||
` : ""}</div>
|
||||
<pre id="pluginDryRunResult" class="log-output"></pre>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h3>Secrets</h3>
|
||||
<div class="chips">${(plugin.secrets || []).map(secretChip).join("") || `<span class="chip">No secrets</span>`}</div>
|
||||
${canWrite ? `
|
||||
<form id="pluginSecretForm" class="inline-form">
|
||||
<input name="name" placeholder="secret name" required>
|
||||
<input name="value" type="password" placeholder="value" required>
|
||||
<label class="inline"><input name="reload_required" type="checkbox"> Reload required</label>
|
||||
<label class="inline"><input name="hot_reload" type="checkbox" checked> Hot reload</label>
|
||||
<button type="submit">Update secret</button>
|
||||
</form>
|
||||
` : ""}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h3>Artifacts</h3>
|
||||
${artifactList(plugin.artifacts || [], plugin)}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h3>Builds</h3>
|
||||
${buildList(plugin.builds || [], plugin, canWrite)}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h3>Snapshots</h3>
|
||||
${snapshotList(plugin.snapshots || [], canWrite)}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h3>Dispatch plan</h3>
|
||||
<pre class="log-output">${escapeHTML(formatJSON(plugin.dispatch_summary || []))}</pre>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h3>Extension status</h3>
|
||||
<pre class="log-output">${escapeHTML(formatJSON(plugin.extension_status || {}))}</pre>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h3>Operations</h3>
|
||||
<div class="row-actions">
|
||||
<button class="secondary" type="button" id="pluginOperationsLoadBtn">Refresh</button>
|
||||
<button class="secondary" type="button" id="pluginOperationsGCDryRunBtn">GC dry-run</button>
|
||||
<button class="secondary" type="button" id="pluginDiagnosticBtn">Diagnostic</button>
|
||||
</div>
|
||||
<pre id="pluginOperationsOutput" class="log-output"></pre>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h3>Active proxy connections</h3>
|
||||
${proxyConnectionList(plugin.proxy_connections || [])}
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
bindPluginDetailEvents(plugin);
|
||||
}
|
||||
|
||||
export function bindPluginEvents(): void {
|
||||
// 顶层插件页事件只绑定一次;详情区会在每次重绘后重新绑定动态按钮。
|
||||
el<HTMLInputElement>("pluginUploadInput").addEventListener("change", uploadPluginPackage);
|
||||
el<HTMLButtonElement>("refreshPluginsBtn").addEventListener("click", loadPlugins);
|
||||
}
|
||||
|
||||
function renderPluginServicePanel(): void {
|
||||
const container = document.getElementById("pluginServicePanel");
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const service = state.pluginService?.service;
|
||||
const canWrite = isAdmin();
|
||||
// 插件服务模式决定插件在进程内运行还是进入未来的独立/沙箱运行模式。
|
||||
container.innerHTML = `
|
||||
<section class="panel">
|
||||
<div class="detail-header compact">
|
||||
<div>
|
||||
<h3>Plugin Service</h3>
|
||||
<p>${service ? `active ${escapeHTML(service.active_mode)} · desired ${escapeHTML(service.desired_mode)}` : "not loaded"}</p>
|
||||
</div>
|
||||
${service ? badge(service.restart_required ? "restart required" : "applied", service.restart_required) : ""}
|
||||
</div>
|
||||
${service ? `
|
||||
<div class="status-grid dense">
|
||||
${detailStat("Desired mode", service.desired_mode)}
|
||||
${detailStat("Active mode", service.active_mode)}
|
||||
${detailStat("Migration", service.live_migration || "drain-only")}
|
||||
${detailStat("Restart", service.restart_required ? "required" : "not required")}
|
||||
</div>
|
||||
${service.last_error ? `<div class="alert inline-alert">${escapeHTML(service.last_error)}</div>` : ""}
|
||||
${canWrite ? `
|
||||
<form id="pluginServiceForm" class="inline-form">
|
||||
<select name="desired_mode">
|
||||
${["in-process", "go-plugin-process", "sandbox-process"].map((mode) => `<option value="${mode}" ${mode === service.desired_mode ? "selected" : ""}>${mode}</option>`).join("")}
|
||||
</select>
|
||||
<button type="submit">Set desired</button>
|
||||
</form>
|
||||
` : ""}
|
||||
` : ""}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h3>Build-Time Instrumentation</h3>
|
||||
${instrumentationList(state.pluginInstrumentation)}
|
||||
</section>
|
||||
`;
|
||||
const form = document.getElementById("pluginServiceForm");
|
||||
if (form instanceof HTMLFormElement) {
|
||||
form.addEventListener("submit", updatePluginServiceMode);
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePluginServiceMode(event: Event): Promise<void> {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget as HTMLFormElement;
|
||||
try {
|
||||
// 服务模式变更可能需要后端迁移或重启,因此保存后立即刷新插件页状态。
|
||||
await api("/plugin-service", {
|
||||
method: "PUT",
|
||||
body: { desired_mode: getFormInput(form, "desired_mode") },
|
||||
});
|
||||
await loadPlugins();
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
function instrumentationList(records: PluginInstrumentation[]): string {
|
||||
if (!records.length) {
|
||||
return `<p class="muted">No instrumentation metadata</p>`;
|
||||
}
|
||||
return `<table class="mini-table">
|
||||
<thead><tr><th>Name</th><th>Profile</th><th>Status</th><th>Diff</th><th>Rollback</th></tr></thead>
|
||||
<tbody>${records.map((record) => `
|
||||
<tr>
|
||||
<td>${escapeHTML(record.name)} ${escapeHTML(record.version || "")}</td>
|
||||
<td>${escapeHTML(record.profile || "")}</td>
|
||||
<td>${badge(record.status || "available", record.status === "blocked")}</td>
|
||||
<td>${escapeHTML(shortID(record.generated_diff_hash || ""))}</td>
|
||||
<td>${escapeHTML(record.runbook_rollback || "")}</td>
|
||||
</tr>
|
||||
`).join("")}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
async function uploadPluginPackage(event: Event): Promise<void> {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.set("artifact", file);
|
||||
try {
|
||||
// 浏览器只负责上传文件;manifest 校验、哈希和制品类型判断由后端完成。
|
||||
await api("/plugin-artifacts", { method: "POST", formData });
|
||||
input.value = "";
|
||||
await loadPlugins();
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
function bindPluginDetailEvents(plugin: PluginView): void {
|
||||
// 详情区每次重绘都会替换 DOM,因此按钮事件必须在重绘后重新绑定。
|
||||
document.getElementById("pluginDryRunBtn")?.addEventListener("click", () => dryRunConfig(plugin));
|
||||
document.getElementById("pluginSaveConfigBtn")?.addEventListener("click", () => saveConfig(plugin));
|
||||
const secretForm = document.getElementById("pluginSecretForm");
|
||||
if (secretForm instanceof HTMLFormElement) {
|
||||
secretForm.addEventListener("submit", (event) => saveSecret(event, plugin));
|
||||
}
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-plugin-action]").forEach((button) => {
|
||||
button.addEventListener("click", () => runPluginAction(plugin.id, button.dataset.pluginAction || ""));
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-artifact-rollback]").forEach((button) => {
|
||||
button.addEventListener("click", () => rollbackArtifact(plugin.id, button.dataset.artifactRollback || ""));
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-build-action]").forEach((button) => {
|
||||
button.addEventListener("click", () => runBuildAction(plugin.id, Number(button.dataset.buildId || "0"), button.dataset.buildAction || ""));
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-snapshot-rollback]").forEach((button) => {
|
||||
button.addEventListener("click", () => rollbackSnapshot(plugin.id, Number(button.dataset.snapshotRollback || "0"), false));
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-snapshot-full-rollback]").forEach((button) => {
|
||||
button.addEventListener("click", () => rollbackSnapshot(plugin.id, Number(button.dataset.snapshotFullRollback || "0"), true));
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-snapshot-diff]").forEach((button) => {
|
||||
button.addEventListener("click", () => showSnapshotDiff(plugin.id, Number(button.dataset.snapshotDiff || "0")));
|
||||
});
|
||||
document.getElementById("pluginGovernanceReviewBtn")?.addEventListener("click", () => createGovernanceReview(plugin));
|
||||
document.getElementById("pluginGovernanceOverrideBtn")?.addEventListener("click", () => createGovernanceOverride(plugin));
|
||||
document.getElementById("pluginGovernancePreflightBtn")?.addEventListener("click", () => runGovernancePreflight(plugin));
|
||||
document.getElementById("pluginGovernanceSelfTestBtn")?.addEventListener("click", () => runGovernanceSelfTest(plugin));
|
||||
document.getElementById("pluginGovernanceBenchmarkBtn")?.addEventListener("click", () => recordGovernanceBenchmark(plugin));
|
||||
document.getElementById("pluginGovernanceAdvisoryBtn")?.addEventListener("click", () => createArtifactRevokeAdvisory(plugin));
|
||||
document.getElementById("pluginOperationsLoadBtn")?.addEventListener("click", () => loadPluginOperations(plugin));
|
||||
document.getElementById("pluginOperationsGCDryRunBtn")?.addEventListener("click", () => dryRunOperationsGC(plugin));
|
||||
document.getElementById("pluginDiagnosticBtn")?.addEventListener("click", () => loadDiagnosticPackage(plugin));
|
||||
}
|
||||
|
||||
async function dryRunConfig(plugin: PluginView): Promise<void> {
|
||||
try {
|
||||
// dry-run 不保存配置,只返回脱敏后的校验结果、diff 和是否需要重启。
|
||||
const data = await api<DryRunResponse>(`/plugins/${encodeURIComponent(plugin.id)}/config/dry-run`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
artifact_id: plugin.desired_artifact_id,
|
||||
config_json: configEditorValue(),
|
||||
},
|
||||
});
|
||||
el("pluginDryRunResult").textContent = formatJSON(data.result || {});
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig(plugin: PluginView): Promise<void> {
|
||||
try {
|
||||
// 配置保存写入期望状态;后端会根据当前制品和运行态判断是否可热加载。
|
||||
await api(`/plugins/${encodeURIComponent(plugin.id)}/config`, {
|
||||
method: "PUT",
|
||||
body: {
|
||||
artifact_id: plugin.desired_artifact_id,
|
||||
desired_state: plugin.desired_state,
|
||||
priority: plugin.priority,
|
||||
config_json: configEditorValue(),
|
||||
},
|
||||
});
|
||||
await loadPluginDetail(plugin.id);
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSecret(event: SubmitEvent, plugin: PluginView): Promise<void> {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget as HTMLFormElement;
|
||||
try {
|
||||
// 密钥值不回显,保存后通过重新加载详情刷新版本号和 reload 标记。
|
||||
await api(`/plugins/${encodeURIComponent(plugin.id)}/secrets`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
name: getFormInput(form, "name").value,
|
||||
artifact_id: plugin.desired_artifact_id,
|
||||
value: getFormInput(form, "value").value,
|
||||
reload_required: getFormInput(form, "reload_required").checked,
|
||||
hot_reload: getFormInput(form, "hot_reload").checked,
|
||||
},
|
||||
});
|
||||
form.reset();
|
||||
await loadPluginDetail(plugin.id);
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function runPluginAction(pluginID: string, action: string): Promise<void> {
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// enable/disable/load/delete 等动作都走统一动作接口,后端负责审计和操作日志。
|
||||
await api(`/plugins/${encodeURIComponent(pluginID)}/${action}`, { method: "POST", body: {} });
|
||||
if (action === "delete") {
|
||||
state.selectedPluginID = "";
|
||||
await loadPlugins();
|
||||
} else {
|
||||
await loadPluginDetail(pluginID);
|
||||
}
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function createDesiredFromArtifact(artifact: PluginArtifact): Promise<void> {
|
||||
try {
|
||||
// 从未纳管制品创建 disabled 期望状态,管理员随后可以编辑配置再启用。
|
||||
await api(`/plugins/${encodeURIComponent(artifact.plugin_id)}`, {
|
||||
method: "PUT",
|
||||
body: {
|
||||
artifact_id: artifact.id,
|
||||
desired_state: "disabled",
|
||||
priority: Number(getFormInput(el<HTMLFormElement>("artifactDesiredForm"), "priority").value || "100"),
|
||||
config_json: el<HTMLTextAreaElement>("artifactConfigEditor").value || "{}",
|
||||
},
|
||||
});
|
||||
state.selectedArtifactID = "";
|
||||
state.selectedPluginID = artifact.plugin_id;
|
||||
await loadPlugins();
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function rollbackArtifact(pluginID: string, artifactID: string): Promise<void> {
|
||||
try {
|
||||
// 制品回滚只改期望制品;后端仍会执行治理检查和配置 dry-run。
|
||||
await api(`/plugins/${encodeURIComponent(pluginID)}/rollback/artifact`, {
|
||||
method: "POST",
|
||||
body: { artifact_id: artifactID },
|
||||
});
|
||||
await loadPluginDetail(pluginID);
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function runBuildAction(pluginID: string, buildID: number, action: string): Promise<void> {
|
||||
if (!buildID || !action) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 构建动作可能耗时,当前界面以刷新详情的方式展示最新构建状态。
|
||||
await api(`/plugin-builds/${buildID}/${encodeURIComponent(action)}`, { method: "POST", body: {} });
|
||||
await loadPluginDetail(pluginID);
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function rollbackSnapshot(pluginID: string, snapshotID: number, fullDesired: boolean): Promise<void> {
|
||||
if (!snapshotID) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 配置快照回滚可只恢复配置,也可连同 artifact/desired state/priority 一起恢复。
|
||||
await api(`/plugins/${encodeURIComponent(pluginID)}/rollback/config`, {
|
||||
method: "POST",
|
||||
body: { snapshot_id: snapshotID, full_desired: fullDesired },
|
||||
});
|
||||
await loadPluginDetail(pluginID);
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function showSnapshotDiff(pluginID: string, snapshotID: number): Promise<void> {
|
||||
if (!snapshotID) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// diff 已由后端脱敏,前端只负责展示结果给管理员确认。
|
||||
const data = await api<SnapshotDiffResponse>(`/plugins/${encodeURIComponent(pluginID)}/config/snapshots/${snapshotID}/diff`);
|
||||
el("pluginDryRunResult").textContent = formatJSON(data.diff || {});
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function createGovernanceReview(plugin: PluginView): Promise<void> {
|
||||
try {
|
||||
// 评审记录绑定当前 desired artifact 和配置哈希,用于后续启用或回滚门禁。
|
||||
await api(`/plugins/${encodeURIComponent(plugin.id)}/governance/review`, {
|
||||
method: "POST",
|
||||
body: { artifact_id: plugin.desired_artifact_id, profile: "prod", decision: "approved" },
|
||||
});
|
||||
await loadPluginDetail(plugin.id);
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function createGovernanceOverride(plugin: PluginView): Promise<void> {
|
||||
const reason = window.prompt("Reason");
|
||||
if (!reason) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// override 是带 TTL 的临时治理豁免,必须记录人工原因。
|
||||
await api(`/plugins/${encodeURIComponent(plugin.id)}/governance/override`, {
|
||||
method: "POST",
|
||||
body: { artifact_id: plugin.desired_artifact_id, profile: "prod", action: "enable", reason, ttl_seconds: 3600 },
|
||||
});
|
||||
await loadPluginDetail(plugin.id);
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function runGovernancePreflight(plugin: PluginView): Promise<void> {
|
||||
try {
|
||||
// preflight 由插件或宿主返回检查项,结果会持久化到治理面板。
|
||||
const data = await api<Record<string, unknown>>(`/plugins/${encodeURIComponent(plugin.id)}/governance/preflight`, {
|
||||
method: "POST",
|
||||
body: { artifact_id: plugin.desired_artifact_id, config_json: configEditorValue() },
|
||||
});
|
||||
el("pluginDryRunResult").textContent = formatJSON(data);
|
||||
await loadPluginDetail(plugin.id);
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function runGovernanceSelfTest(plugin: PluginView): Promise<void> {
|
||||
try {
|
||||
// self-test 用于验证制品自身能力,不直接修改 desired state。
|
||||
const data = await api<Record<string, unknown>>(`/plugins/${encodeURIComponent(plugin.id)}/governance/self-test`, {
|
||||
method: "POST",
|
||||
body: { artifact_id: plugin.desired_artifact_id },
|
||||
});
|
||||
el("pluginDryRunResult").textContent = formatJSON(data);
|
||||
await loadPluginDetail(plugin.id);
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function recordGovernanceBenchmark(plugin: PluginView): Promise<void> {
|
||||
const diff = Number(window.prompt("Baseline diff, e.g. 0.25", "0.25"));
|
||||
if (!Number.isFinite(diff)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 手动录入基准差异用于治理门禁判断,避免高风险性能回退直接启用。
|
||||
await api(`/plugins/${encodeURIComponent(plugin.id)}/governance/benchmark`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
artifact_id: plugin.desired_artifact_id,
|
||||
profile: "prod",
|
||||
benchmark_profile: "manual",
|
||||
p95_ms: 0,
|
||||
p99_ms: 0,
|
||||
error_rate: 0,
|
||||
active_proxy_capacity: 0,
|
||||
baseline_diff: diff,
|
||||
},
|
||||
});
|
||||
await loadPluginDetail(plugin.id);
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function createArtifactRevokeAdvisory(plugin: PluginView): Promise<void> {
|
||||
const artifact = plugin.desired_artifact;
|
||||
if (!artifact) {
|
||||
return;
|
||||
}
|
||||
const advisoryID = window.prompt("Advisory ID", `local-${shortID(artifact.sha256)}`);
|
||||
if (!advisoryID) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 撤销公告会让命中的制品进入隔离/阻断路径,详情刷新后展示最新治理状态。
|
||||
await api("/plugin-advisories", {
|
||||
method: "POST",
|
||||
body: {
|
||||
advisory_id: advisoryID,
|
||||
status: "revoked",
|
||||
action: "revoke",
|
||||
artifact_sha256: artifact.sha256,
|
||||
recommended_action: "rollback or upgrade",
|
||||
},
|
||||
});
|
||||
await loadPluginDetail(plugin.id);
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPluginOperations(plugin: PluginView): Promise<void> {
|
||||
try {
|
||||
// 运维快照包含事件、日志、trace、任务、外部依赖和 GC 候选项,按需刷新即可。
|
||||
const data = await api<OperationsResponse>(`/plugins/${encodeURIComponent(plugin.id)}/operations`);
|
||||
el("pluginOperationsOutput").textContent = formatJSON(data.operations || {});
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function dryRunOperationsGC(plugin: PluginView): Promise<void> {
|
||||
try {
|
||||
// GC dry-run 不删除文件,只展示哪些运行态数据会被保护或清理。
|
||||
const data = await api<OperationsResponse>(`/plugins/${encodeURIComponent(plugin.id)}/operations/gc`);
|
||||
el("pluginOperationsOutput").textContent = formatJSON(data);
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDiagnosticPackage(plugin: PluginView): Promise<void> {
|
||||
try {
|
||||
// 诊断包由后端生成并脱敏,前端以 JSON 文本形式展示给管理员。
|
||||
const data = await api<OperationsResponse>(`/plugins/${encodeURIComponent(plugin.id)}/operations/diagnostic`);
|
||||
el("pluginOperationsOutput").textContent = formatJSON(data);
|
||||
showAlert("");
|
||||
} catch (err) {
|
||||
showAlert((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
function uploadInventoryDetail(): string {
|
||||
// 库存视图聚合未纳管制品和构建记录,支撑上传、构建、纳管的完整流程。
|
||||
const artifact = selectedArtifact();
|
||||
if (!artifact) {
|
||||
return `
|
||||
<div class="plugin-layout">
|
||||
<section class="panel">
|
||||
<h3>Uploaded artifacts</h3>
|
||||
${artifactInventoryList(state.pluginArtifacts)}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h3>Builds</h3>
|
||||
${buildInventoryList(state.pluginBuilds)}
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
const canWrite = isAdmin() && artifact.artifact_type === "binary";
|
||||
return `
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
<h2>${escapeHTML(artifact.plugin_id)}</h2>
|
||||
<p>${escapeHTML(artifact.version)} · ${escapeHTML(artifact.artifact_type)} · ${escapeHTML(shortID(artifact.id))}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="plugin-layout">
|
||||
<section class="panel">
|
||||
<h3>Artifact</h3>
|
||||
<dl class="kv">
|
||||
<dt>Status</dt><dd>${escapeHTML(artifact.status)}</dd>
|
||||
<dt>Runtime</dt><dd>${escapeHTML(artifact.runtime_type || "")}</dd>
|
||||
<dt>Go/API</dt><dd>${escapeHTML(`${artifact.go_version || ""} ${artifact.api_version || ""}`)}</dd>
|
||||
<dt>SHA256</dt><dd>${escapeHTML(artifact.sha256)}</dd>
|
||||
<dt>Extensions</dt><dd>${escapeHTML(jsonList(artifact.extension_points_json))}</dd>
|
||||
<dt>Error</dt><dd>${escapeHTML(artifact.error || "")}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h3>Create desired state</h3>
|
||||
${canWrite ? `
|
||||
<form id="artifactDesiredForm" class="inline-form">
|
||||
<label>Priority<input name="priority" type="number" value="100"></label>
|
||||
<textarea id="artifactConfigEditor">{}</textarea>
|
||||
<button type="submit">Create disabled plugin</button>
|
||||
</form>
|
||||
` : `<div class="empty">Only binary artifacts can be used as plugin desired state.</div>`}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h3>Builds</h3>
|
||||
${buildInventoryList(state.pluginBuilds.filter((build) => build.plugin_id === artifact.plugin_id))}
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function bindInventoryEvents(): void {
|
||||
// 库存视图也是动态渲染,制品详情和纳管表单事件需要在渲染后绑定。
|
||||
const artifact = selectedArtifact();
|
||||
const form = document.getElementById("artifactDesiredForm");
|
||||
if (artifact && form instanceof HTMLFormElement) {
|
||||
form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
createDesiredFromArtifact(artifact);
|
||||
});
|
||||
}
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-artifact-detail]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
state.selectedArtifactID = button.dataset.artifactDetail || "";
|
||||
renderPlugins();
|
||||
renderPluginDetail(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function artifactInventoryList(artifacts: PluginArtifact[]): string {
|
||||
if (artifacts.length === 0) {
|
||||
return `<div class="empty">No artifacts</div>`;
|
||||
}
|
||||
return `<div class="mini-list">${artifacts.map((artifact) => `
|
||||
<div class="mini-row">
|
||||
<span>${escapeHTML(artifact.plugin_id)}</span>
|
||||
<span>${escapeHTML(artifact.version)} · ${escapeHTML(artifact.artifact_type)} · ${escapeHTML(artifact.status)}</span>
|
||||
<span>${escapeHTML(shortID(artifact.id))}</span>
|
||||
<button class="secondary" type="button" data-artifact-detail="${escapeAttr(artifact.id)}">Open</button>
|
||||
</div>
|
||||
`).join("")}</div>`;
|
||||
}
|
||||
|
||||
function buildInventoryList(builds: PluginBuild[]): string {
|
||||
if (builds.length === 0) {
|
||||
return `<div class="empty">No builds</div>`;
|
||||
}
|
||||
return `<div class="mini-list">${builds.map((build) => `
|
||||
<div class="mini-row">
|
||||
<span>${escapeHTML(build.plugin_id)} #${escapeHTML(build.id)}</span>
|
||||
<span>${badge(build.status, build.status !== "succeeded")}</span>
|
||||
<span>${escapeHTML(shortID(build.artifact_id || build.source_id))}</span>
|
||||
<span>${escapeHTML(build.log_summary || build.error || "")}</span>
|
||||
</div>
|
||||
`).join("")}</div>`;
|
||||
}
|
||||
|
||||
function selectedPlugin(): PluginView | null {
|
||||
return state.plugins.find((plugin) => plugin.id === state.selectedPluginID) || null;
|
||||
}
|
||||
|
||||
function selectedArtifact(): PluginArtifact | null {
|
||||
return state.pluginArtifacts.find((artifact) => artifact.id === state.selectedArtifactID) || null;
|
||||
}
|
||||
|
||||
function pluginActionButtons(plugin: PluginView): string {
|
||||
return `
|
||||
<button type="button" data-plugin-action="load">Load</button>
|
||||
<button type="button" data-plugin-action="enable">Enable</button>
|
||||
<button class="secondary" type="button" data-plugin-action="disable">Disable</button>
|
||||
<button class="danger" type="button" data-plugin-action="delete">Delete</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function governancePanel(plugin: PluginView, canWrite: boolean): string {
|
||||
if (plugin.governance_error) {
|
||||
return `<div class="alert inline-alert">${escapeHTML(plugin.governance_error)}</div>`;
|
||||
}
|
||||
const governance = plugin.governance;
|
||||
const decision = governance?.decision;
|
||||
const issues = decision?.issues || [];
|
||||
return `
|
||||
<dl class="kv">
|
||||
<dt>Profile</dt><dd>${escapeHTML(decision?.profile || "")}</dd>
|
||||
<dt>Risk</dt><dd>${escapeHTML(decision?.risk_level || "")}</dd>
|
||||
<dt>Policy</dt><dd>${escapeHTML(shortID(decision?.policy_hash || ""))}</dd>
|
||||
<dt>Decision</dt><dd>${badge(decision?.ok ? "allowed" : "blocked", !decision?.ok)}</dd>
|
||||
<dt>Review</dt><dd>${badge(decision?.review_required ? "required" : "not required", Boolean(decision?.review_required))}</dd>
|
||||
<dt>Override</dt><dd>${badge(decision?.warning_override_used ? "used" : "not used", Boolean(decision?.warning_override_used))}</dd>
|
||||
</dl>
|
||||
${issues.length ? `<div class="mini-list">${issues.map((issue) => `
|
||||
<div class="mini-row">
|
||||
<span>${badge(issue.severity, issue.severity !== "info")}</span>
|
||||
<span>${escapeHTML(issue.code)}</span>
|
||||
<span>${escapeHTML(issue.message)}</span>
|
||||
</div>
|
||||
`).join("")}</div>` : `<div class="empty">No governance issues</div>`}
|
||||
${canWrite ? `
|
||||
<div class="row-actions">
|
||||
<button class="secondary" type="button" id="pluginGovernanceReviewBtn">Review</button>
|
||||
<button class="secondary" type="button" id="pluginGovernanceOverrideBtn">Override</button>
|
||||
<button class="secondary" type="button" id="pluginGovernancePreflightBtn">Preflight</button>
|
||||
<button class="secondary" type="button" id="pluginGovernanceSelfTestBtn">Self-test</button>
|
||||
<button class="secondary" type="button" id="pluginGovernanceBenchmarkBtn">Benchmark</button>
|
||||
<button class="danger" type="button" id="pluginGovernanceAdvisoryBtn">Revoke artifact</button>
|
||||
</div>
|
||||
` : ""}
|
||||
<pre class="log-output">${escapeHTML(formatJSON({
|
||||
conflicts: governance?.conflicts,
|
||||
reviews: governance?.reviews || [],
|
||||
warning_overrides: governance?.warning_overrides || [],
|
||||
preflights: governance?.preflights || [],
|
||||
benchmarks: governance?.benchmarks || [],
|
||||
advisories: governance?.advisories || [],
|
||||
}))}</pre>
|
||||
`;
|
||||
}
|
||||
|
||||
function artifactList(artifacts: PluginArtifact[], plugin: PluginView): string {
|
||||
if (artifacts.length === 0) {
|
||||
return `<div class="empty">No artifacts</div>`;
|
||||
}
|
||||
return `<div class="mini-list">${artifacts.map((artifact) => `
|
||||
<div class="mini-row">
|
||||
<span>${escapeHTML(shortID(artifact.id))}</span>
|
||||
<span>${escapeHTML(artifact.version)} · ${escapeHTML(artifact.artifact_type)} · ${escapeHTML(artifact.status)}</span>
|
||||
<span>${escapeHTML(artifact.go_version || "")}</span>
|
||||
${isAdmin() && artifact.artifact_type === "binary" && artifact.id !== plugin.desired_artifact_id ? `<button class="secondary" type="button" data-artifact-rollback="${escapeAttr(artifact.id)}">Rollback</button>` : ""}
|
||||
</div>
|
||||
`).join("")}</div>`;
|
||||
}
|
||||
|
||||
function buildList(builds: PluginBuild[], plugin: PluginView, canWrite: boolean): string {
|
||||
if (builds.length === 0) {
|
||||
return `<div class="empty">No builds</div>`;
|
||||
}
|
||||
return `<div class="mini-list">${builds.map((build) => `
|
||||
<div class="mini-row">
|
||||
<span>#${escapeHTML(build.id)}</span>
|
||||
<span>${badge(build.status, build.status !== "succeeded")}</span>
|
||||
<span>${escapeHTML(shortID(build.artifact_id || build.source_id))}</span>
|
||||
<span>${escapeHTML(build.log_summary || build.error || "")}</span>
|
||||
${canWrite ? buildActions(build, plugin) : ""}
|
||||
</div>
|
||||
`).join("")}</div>`;
|
||||
}
|
||||
|
||||
function buildActions(build: PluginBuild, plugin: PluginView): string {
|
||||
if (build.status === "queued") {
|
||||
return `<button class="secondary" type="button" data-build-id="${escapeAttr(build.id)}" data-build-action="run">Run</button>`;
|
||||
}
|
||||
if (build.status === "running") {
|
||||
return `<button class="secondary" type="button" data-build-id="${escapeAttr(build.id)}" data-build-action="cancel">Cancel</button>`;
|
||||
}
|
||||
if (build.status === "failed" || build.status === "canceled") {
|
||||
return `<button class="secondary" type="button" data-build-id="${escapeAttr(build.id)}" data-build-action="retry">Retry</button>`;
|
||||
}
|
||||
if (build.status === "succeeded" && build.artifact_id && build.artifact_id !== plugin.desired_artifact_id) {
|
||||
return `<button class="secondary" type="button" data-artifact-rollback="${escapeAttr(build.artifact_id)}">Use artifact</button>`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function snapshotList(snapshots: PluginSnapshot[], canWrite: boolean): string {
|
||||
if (snapshots.length === 0) {
|
||||
return `<div class="empty">No snapshots</div>`;
|
||||
}
|
||||
return `<div class="mini-list">${snapshots.map((snapshot) => `
|
||||
<div class="mini-row">
|
||||
<span>#${escapeHTML(snapshot.id)}</span>
|
||||
<span>gen ${escapeHTML(snapshot.desired_generation)} · ${escapeHTML(snapshot.desired_state)} · ${escapeHTML(shortID(snapshot.artifact_id))}</span>
|
||||
<button class="secondary" type="button" data-snapshot-diff="${escapeAttr(snapshot.id)}">Diff</button>
|
||||
${canWrite ? `
|
||||
<button class="secondary" type="button" data-snapshot-rollback="${escapeAttr(snapshot.id)}">Config rollback</button>
|
||||
<button class="secondary" type="button" data-snapshot-full-rollback="${escapeAttr(snapshot.id)}">Full rollback</button>
|
||||
` : ""}
|
||||
</div>
|
||||
`).join("")}</div>`;
|
||||
}
|
||||
|
||||
function proxyConnectionList(connections: PluginProxyConnection[]): string {
|
||||
if (connections.length === 0) {
|
||||
return `<div class="empty">No active proxy connections</div>`;
|
||||
}
|
||||
return `<div class="mini-list">${connections.map((conn) => `
|
||||
<div class="mini-row">
|
||||
<span>#${escapeHTML(conn.id)}</span>
|
||||
<span>${escapeHTML(shortID(conn.artifact_id))} · ${escapeHTML(conn.handler_id)}</span>
|
||||
<span>${escapeHTML(conn.duration_ms)}ms</span>
|
||||
<span>${badge(conn.draining ? "draining" : "active", conn.draining)}</span>
|
||||
</div>
|
||||
`).join("")}</div>`;
|
||||
}
|
||||
|
||||
function secretChip(secret: PluginSecret): string {
|
||||
const reload = secret.reload_required ? "reload required" : "hot reload";
|
||||
return `<span class="chip">${escapeHTML(secret.name)} v${escapeHTML(secret.current_version)} · prev ${escapeHTML(secret.previous_version)} · ${escapeHTML(reload)}</span>`;
|
||||
}
|
||||
|
||||
function detailStat(label: string, value: unknown): string {
|
||||
return `<div class="stat"><span>${escapeHTML(label)}</span><strong>${escapeHTML(String(value ?? ""))}</strong></div>`;
|
||||
}
|
||||
|
||||
function configEditorValue(): string {
|
||||
return el<HTMLTextAreaElement>("pluginConfigEditor").value;
|
||||
}
|
||||
|
||||
function shortID(id?: string): string {
|
||||
return id ? id.slice(0, 12) : "";
|
||||
}
|
||||
|
||||
function prettyJSON(raw: string): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(raw), null, 2);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function jsonList(raw?: string): string {
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const value = JSON.parse(raw);
|
||||
return Array.isArray(value) ? value.join(", ") : String(value);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function formatJSON(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value ?? {}, null, 2);
|
||||
} catch {
|
||||
return String(value ?? "");
|
||||
}
|
||||
}
|
||||
110
cmd/gateway/admin_frontend/src/views/routes.ts
Normal file
110
cmd/gateway/admin_frontend/src/views/routes.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
// cmd/gateway/admin_frontend/src/views/routes.ts 渲染路由列表,并通过 Admin API 保存主机到上游的变更。
|
||||
|
||||
import { api } from "../api.js";
|
||||
import { showAlert } from "../alerts.js";
|
||||
import { badge, el, escapeAttr, escapeHTML, getFormInput } from "../dom.js";
|
||||
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>
|
||||
`;
|
||||
}
|
||||
163
cmd/gateway/admin_frontend/src/views/services.ts
Normal file
163
cmd/gateway/admin_frontend/src/views/services.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
// cmd/gateway/admin_frontend/src/views/services.ts 渲染监听服务设置,并持久化启停、端口和选项更新。
|
||||
|
||||
import { api } from "../api.js";
|
||||
import { showAlert } from "../alerts.js";
|
||||
import { el, escapeAttr, escapeHTML, getFormInput } from "../dom.js";
|
||||
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") : [];
|
||||
}
|
||||
27
cmd/gateway/admin_frontend/src/views/status.ts
Normal file
27
cmd/gateway/admin_frontend/src/views/status.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// cmd/gateway/admin_frontend/src/views/status.ts 渲染管理面板上的网关健康摘要。
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
108
cmd/gateway/admin_frontend/src/views/users.ts
Normal file
108
cmd/gateway/admin_frontend/src/views/users.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
// cmd/gateway/admin_frontend/src/views/users.ts 渲染管理用户列表,并保存账号、角色、密码和禁用状态变更。
|
||||
|
||||
import { api } from "../api.js";
|
||||
import { showAlert } from "../alerts.js";
|
||||
import { badge, el, escapeAttr, escapeHTML, getFormInput, getFormSelect } from "../dom.js";
|
||||
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);
|
||||
}
|
||||
}
|
||||
19
cmd/gateway/admin_metric_handlers.go
Normal file
19
cmd/gateway/admin_metric_handlers.go
Normal file
@@ -0,0 +1,19 @@
|
||||
// cmd/gateway/admin_metric_handlers.go 返回管理面板状态卡片使用的轻量运行时计数器。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tursom/mc-gateway/internal/adminhttp"
|
||||
"github.com/tursom/mc-gateway/internal/gatewaymetrics"
|
||||
)
|
||||
|
||||
var gatewayMetrics = gatewaymetrics.New()
|
||||
|
||||
func handleAdminMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireRole(w, r, adminRoleMember); !ok {
|
||||
return
|
||||
}
|
||||
adminhttp.WriteJSON(w, http.StatusOK, gatewayMetrics.Snapshot())
|
||||
}
|
||||
1533
cmd/gateway/admin_plugin_handlers.go
Normal file
1533
cmd/gateway/admin_plugin_handlers.go
Normal file
File diff suppressed because it is too large
Load Diff
64
cmd/gateway/admin_route_handlers.go
Normal file
64
cmd/gateway/admin_route_handlers.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// cmd/gateway/admin_route_handlers.go 提供修改路由记录的 HTTP 接口,并在提交后立刻刷新内存路由快照。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tursom/mc-gateway/internal/adminhttp"
|
||||
)
|
||||
|
||||
func handleAdminRoutesList(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireRole(w, r, adminRoleGuest); !ok {
|
||||
return
|
||||
}
|
||||
routes, err := listRoutes(r.Context(), r.URL.Query().Get("q"))
|
||||
if err != nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"routes": routes})
|
||||
}
|
||||
|
||||
func handleAdminRouteItem(w http.ResponseWriter, r *http.Request, rawHost string) {
|
||||
session, ok := requireRole(w, r, adminRoleMember)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
host, err := adminhttp.PathSegment(rawHost)
|
||||
if err != nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodPut:
|
||||
var req adminhttp.RouteRequest
|
||||
if !adminhttp.DecodeJSONRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
err := upsertRoute(r.Context(), session.Username, host, req.Upstream, enabled, req.Note)
|
||||
if err != nil {
|
||||
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "route_upsert", "route", host, false, err.Error())
|
||||
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "route_upsert", "route", host, true, "route saved")
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
case http.MethodDelete:
|
||||
err := deleteRoute(r.Context(), session.Username, host)
|
||||
if err != nil {
|
||||
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "route_delete", "route", host, false, err.Error())
|
||||
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "route_delete", "route", host, true, "route deleted")
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
default:
|
||||
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
}
|
||||
70
cmd/gateway/admin_routes.go
Normal file
70
cmd/gateway/admin_routes.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// cmd/gateway/admin_routes.go 让内存中的主机到上游映射快照与 SQLite 路由记录保持同步。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/tursom/mc-gateway/internal/adminroute"
|
||||
)
|
||||
|
||||
var (
|
||||
// routeSnapshot 是连接热路径读取的不可变快照;写路径通过 Store 整体替换它。
|
||||
routeSnapshot = adminroute.NewSnapshot()
|
||||
// routeWriteLock 串行化路由写入和快照刷新,避免并发写导致后写库、先发布的顺序错乱。
|
||||
routeWriteLock sync.Mutex
|
||||
)
|
||||
|
||||
// refreshRouteSnapshot 从 SQLite 读取启用路由并发布到热路径。数据库尚未初始化时
|
||||
// 发布空快照,方便测试和早期启动路径调用。
|
||||
func refreshRouteSnapshot(ctx context.Context) error {
|
||||
if adminDB == nil {
|
||||
publishRouteSnapshot(map[string]string{})
|
||||
return nil
|
||||
}
|
||||
|
||||
routes, err := adminroute.NewRepository(adminDB).EnabledMap(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
publishRouteSnapshot(routes)
|
||||
return nil
|
||||
}
|
||||
|
||||
// publishRouteSnapshot 原子替换当前路由快照;调用方应传入新 map,避免发布后继续修改。
|
||||
func publishRouteSnapshot(routes map[string]string) {
|
||||
routeSnapshot.Store(routes)
|
||||
}
|
||||
|
||||
// lookupRoute 是连接热路径使用的只读查找函数,不访问 SQLite。
|
||||
func lookupRoute(host string) (string, bool) {
|
||||
return routeSnapshot.Lookup(host)
|
||||
}
|
||||
|
||||
func listRoutes(ctx context.Context, query string) ([]adminroute.Record, error) {
|
||||
return adminroute.NewRepository(adminDB).List(ctx, query)
|
||||
}
|
||||
|
||||
func upsertRoute(ctx context.Context, actor, host, upstream string, enabled bool, note string) error {
|
||||
routeWriteLock.Lock()
|
||||
defer routeWriteLock.Unlock()
|
||||
|
||||
// 路由写入成功后必须立即刷新内存快照,否则管理端保存的配置不会影响新连接。
|
||||
if err := adminroute.NewRepository(adminDB).Upsert(ctx, actor, host, upstream, enabled, note); err != nil {
|
||||
return err
|
||||
}
|
||||
return refreshRouteSnapshot(ctx)
|
||||
}
|
||||
|
||||
func deleteRoute(ctx context.Context, actor, host string) error {
|
||||
routeWriteLock.Lock()
|
||||
defer routeWriteLock.Unlock()
|
||||
|
||||
// 删除也走同一把锁,确保快照刷新顺序与数据库提交顺序一致。
|
||||
if err := adminroute.NewRepository(adminDB).Delete(ctx, host); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = actor
|
||||
return refreshRouteSnapshot(ctx)
|
||||
}
|
||||
113
cmd/gateway/admin_runtime.go
Normal file
113
cmd/gateway/admin_runtime.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// cmd/gateway/admin_runtime.go 打开 SQLite 运行态数据库、写入默认数据,并为在线流量发布首个路由快照。
|
||||
|
||||
package main
|
||||
|
||||
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 (
|
||||
defaultAdminDBPath = adminconfig.DefaultDBPath
|
||||
defaultAdminPath = adminconfig.DefaultAdminPath
|
||||
defaultAdminAPIPrefix = adminconfig.DefaultAdminAPIPrefix
|
||||
defaultAdminSessionTTL = adminconfig.DefaultSessionTTL
|
||||
defaultKCPPort = adminservice.DefaultKCPPort
|
||||
defaultKCPDataShards = adminservice.DefaultKCPDataShards
|
||||
defaultKCPParityShards = adminservice.DefaultKCPParityShards
|
||||
defaultQUICPort = adminservice.DefaultQUICPort
|
||||
defaultWebSocketPort = adminservice.DefaultWebSocketPort
|
||||
defaultWebSocketPath = adminservice.DefaultWebSocketPath
|
||||
serviceNameTCPAdmin = adminservice.NameTCPAdmin
|
||||
serviceNameKCP = adminservice.NameKCP
|
||||
serviceNameQUIC = adminservice.NameQUIC
|
||||
serviceNameWebSocket = adminservice.NameWebSocket
|
||||
adminEnvDB = adminconfig.EnvDB
|
||||
adminEnvTCPAdminPort = adminconfig.EnvTCPAdminPort
|
||||
adminEnvPath = adminconfig.EnvPath
|
||||
adminEnvAPIPrefix = adminconfig.EnvAPIPrefix
|
||||
adminEnvInitialPassword = adminconfig.EnvInitialPassword
|
||||
)
|
||||
|
||||
var (
|
||||
// adminStartup 是启动时解析出的管理端配置;后续 HTTP handler 和静态资源注入都会读取它。
|
||||
adminStartup = adminconfig.Config{
|
||||
DBPath: defaultAdminDBPath,
|
||||
TCPAdminPort: defaultTCPPort,
|
||||
AdminPath: defaultAdminPath,
|
||||
AdminAPIPrefix: defaultAdminAPIPrefix,
|
||||
SessionTTL: defaultAdminSessionTTL,
|
||||
}
|
||||
|
||||
adminDB *sql.DB
|
||||
adminDBPath string
|
||||
pluginsManager *pluginmanager.Manager
|
||||
processStartAt = time.Now()
|
||||
)
|
||||
|
||||
// initializeGatewayRuntime 按固定顺序准备运行态:解析配置、打开数据库、迁移 schema、
|
||||
// 写入默认服务、应用服务配置、创建初始管理员、发布路由快照、最后启动插件管理器。
|
||||
func initializeGatewayRuntime() error {
|
||||
startup, err := parseStartupConfig(os.Getenv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
adminStartup = startup
|
||||
|
||||
db, err := admindb.Open(startup.DBPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if adminDB != nil && adminDB != db {
|
||||
_ = adminDB.Close()
|
||||
}
|
||||
adminDB = db
|
||||
adminDBPath = startup.DBPath
|
||||
|
||||
if err := admindb.Migrate(db); err != nil {
|
||||
return err
|
||||
}
|
||||
// 默认服务必须先存在,applyServiceConfig 才能把 SQLite 中的运行态端口写回 config。
|
||||
if err := ensureDefaultServices(context.Background(), db, startup.TCPAdminPort); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := applyServiceConfig(context.Background(), db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureInitialAdminFromEnv(context.Background(), db, os.Getenv(adminEnvInitialPassword)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := refreshRouteSnapshot(context.Background()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 插件制品放在数据库同级目录下,便于容器挂载一个 data volume 即可保留全部运行态。
|
||||
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())
|
||||
}
|
||||
|
||||
// closeGatewayRuntime 只关闭当前进程持有的数据库连接;SQLite 文件和插件制品都保留在数据目录中。
|
||||
func closeGatewayRuntime() {
|
||||
if adminDB != nil {
|
||||
_ = adminDB.Close()
|
||||
adminDB = nil
|
||||
}
|
||||
}
|
||||
|
||||
func parseStartupConfig(getenv func(string) string) (adminconfig.Config, error) {
|
||||
return adminconfig.Parse(getenv)
|
||||
}
|
||||
76
cmd/gateway/admin_service_handlers.go
Normal file
76
cmd/gateway/admin_service_handlers.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// cmd/gateway/admin_service_handlers.go 提供监听服务配置接口,用于维护端口、启停状态和是否需要重启。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/tursom/mc-gateway/internal/adminhttp"
|
||||
"github.com/tursom/mc-gateway/internal/adminservice"
|
||||
)
|
||||
|
||||
func handleAdminServicesList(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireRole(w, r, adminRoleMember); !ok {
|
||||
return
|
||||
}
|
||||
services, err := listServiceConfigs(r.Context(), adminDB)
|
||||
if err != nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"services": services})
|
||||
}
|
||||
|
||||
func handleAdminServiceItem(w http.ResponseWriter, r *http.Request, rawName string) {
|
||||
session, ok := requireRole(w, r, adminRoleAdmin)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasSuffix(rawName, "/restart") {
|
||||
name := strings.TrimSuffix(rawName, "/restart")
|
||||
if r.Method != http.MethodPost {
|
||||
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "service_restart", "service", name, false, "restart is not implemented")
|
||||
adminhttp.WriteAPIError(w, http.StatusNotImplemented, "service restart is not implemented; restart the gateway process")
|
||||
return
|
||||
}
|
||||
|
||||
name, err := adminhttp.PathSegment(rawName)
|
||||
if err != nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPut {
|
||||
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
var req adminhttp.ServiceRequest
|
||||
if !adminhttp.DecodeJSONRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
if req.Port == 0 {
|
||||
req.Port = defaultServicePort(name)
|
||||
}
|
||||
|
||||
err = updateServiceConfig(r.Context(), session.Username, name, enabled, req.Port, req.Options)
|
||||
if err != nil {
|
||||
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "service_update", "service", name, false, err.Error())
|
||||
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "service_update", "service", name, true, "service saved")
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "restart_required": true})
|
||||
}
|
||||
|
||||
func defaultServicePort(name string) int {
|
||||
return adminservice.DefaultPort(name, adminStartup.TCPAdminPort)
|
||||
}
|
||||
87
cmd/gateway/admin_services.go
Normal file
87
cmd/gateway/admin_services.go
Normal file
@@ -0,0 +1,87 @@
|
||||
// cmd/gateway/admin_services.go 从 SQLite 加载监听服务配置,并暴露规范化后的运行时服务选项。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/tursom/mc-gateway/internal/adminservice"
|
||||
)
|
||||
|
||||
func ensureDefaultServices(ctx context.Context, db *sql.DB, tcpAdminPort int) error {
|
||||
return adminservice.NewRepository(db).EnsureDefaults(ctx, tcpAdminPort)
|
||||
}
|
||||
|
||||
func applyServiceConfig(ctx context.Context, db *sql.DB) error {
|
||||
services, err := listServiceConfigs(ctx, db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config.Tcp.Enable = true
|
||||
config.Tcp.Port = adminStartup.TCPAdminPort
|
||||
|
||||
for _, service := range services {
|
||||
switch service.Name {
|
||||
case serviceNameKCP:
|
||||
config.Kcp.Enable = service.Enabled
|
||||
config.Kcp.Port = service.Port
|
||||
config.Kcp.DataShards = adminservice.IntOption(service.Options, "data_shards", defaultKCPDataShards)
|
||||
config.Kcp.ParityShards = adminservice.IntOption(service.Options, "parity_shards", defaultKCPParityShards)
|
||||
case serviceNameQUIC:
|
||||
config.Quic.Enable = service.Enabled
|
||||
config.Quic.Port = service.Port
|
||||
config.Quic.ApplicationProtocols = adminservice.StringSliceOption(service.Options, "application_protocols")
|
||||
case serviceNameWebSocket:
|
||||
config.WebSocket.Enable = service.Enabled
|
||||
config.WebSocket.Port = service.Port
|
||||
config.WebSocket.Path = adminservice.StringOption(service.Options, "path", defaultWebSocketPath)
|
||||
}
|
||||
}
|
||||
|
||||
if config.Kcp.Port == 0 {
|
||||
config.Kcp.Port = defaultKCPPort
|
||||
}
|
||||
if config.Quic.Port == 0 {
|
||||
config.Quic.Port = defaultQUICPort
|
||||
}
|
||||
if config.WebSocket.Port == 0 {
|
||||
config.WebSocket.Port = defaultWebSocketPort
|
||||
}
|
||||
if config.WebSocket.Path == "" {
|
||||
config.WebSocket.Path = defaultWebSocketPath
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func listServiceConfigs(ctx context.Context, db *sql.DB) ([]adminservice.Record, error) {
|
||||
services, err := adminservice.NewRepository(db).List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range services {
|
||||
services[i].Running = serviceIsRunning(services[i])
|
||||
}
|
||||
return services, nil
|
||||
}
|
||||
|
||||
func serviceIsRunning(service adminservice.Record) bool {
|
||||
switch service.Name {
|
||||
case serviceNameTCPAdmin:
|
||||
return true
|
||||
case serviceNameKCP:
|
||||
return config.Kcp.Enable
|
||||
case serviceNameQUIC:
|
||||
return config.Quic.Enable
|
||||
case serviceNameWebSocket:
|
||||
return config.WebSocket.Enable
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func updateServiceConfig(ctx context.Context, actor, name string, enabled bool, port int, options map[string]any) error {
|
||||
return adminservice.NewRepository(adminDB).Update(ctx, actor, name, enabled, port, options)
|
||||
}
|
||||
60
cmd/gateway/admin_sessions.go
Normal file
60
cmd/gateway/admin_sessions.go
Normal file
@@ -0,0 +1,60 @@
|
||||
// cmd/gateway/admin_sessions.go 提供 Admin API 认证中间件使用的内存会话管理器。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/tursom/mc-gateway/internal/adminhttp"
|
||||
"github.com/tursom/mc-gateway/internal/adminsession"
|
||||
"github.com/tursom/mc-gateway/internal/adminuser"
|
||||
)
|
||||
|
||||
var adminSessionManager = adminsession.NewManager()
|
||||
|
||||
func createSession(username, role string) (adminsession.Session, error) {
|
||||
return adminSessionManager.Create(username, role, adminStartup.SessionTTL)
|
||||
}
|
||||
|
||||
func getSession(token string) (adminsession.Session, bool) {
|
||||
return adminSessionManager.Get(token)
|
||||
}
|
||||
|
||||
func deleteSession(token string) {
|
||||
adminSessionManager.Delete(token)
|
||||
}
|
||||
|
||||
func removeSessionsForUser(username string) {
|
||||
adminSessionManager.RemoveUser(username)
|
||||
}
|
||||
|
||||
func sessionFromRequest(r *http.Request) (adminsession.Session, bool) {
|
||||
auth := r.Header.Get("Authorization")
|
||||
token, ok := strings.CutPrefix(auth, "Bearer ")
|
||||
if !ok || strings.TrimSpace(token) == "" {
|
||||
return adminsession.Session{}, false
|
||||
}
|
||||
return getSession(strings.TrimSpace(token))
|
||||
}
|
||||
|
||||
func requireSession(w http.ResponseWriter, r *http.Request) (adminsession.Session, bool) {
|
||||
session, ok := sessionFromRequest(r)
|
||||
if !ok {
|
||||
adminhttp.WriteAPIError(w, http.StatusUnauthorized, "login required")
|
||||
return adminsession.Session{}, false
|
||||
}
|
||||
return session, true
|
||||
}
|
||||
|
||||
func requireRole(w http.ResponseWriter, r *http.Request, role string) (adminsession.Session, bool) {
|
||||
session, ok := requireSession(w, r)
|
||||
if !ok {
|
||||
return adminsession.Session{}, false
|
||||
}
|
||||
if !adminuser.HasRole(session.Role, role) {
|
||||
adminhttp.WriteAPIError(w, http.StatusForbidden, "permission denied")
|
||||
return adminsession.Session{}, false
|
||||
}
|
||||
return session, true
|
||||
}
|
||||
20
cmd/gateway/admin_static.go
Normal file
20
cmd/gateway/admin_static.go
Normal file
@@ -0,0 +1,20 @@
|
||||
// cmd/gateway/admin_static.go 嵌入构建后的管理前端,并通过网关 HTTP 处理器对外提供。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tursom/mc-gateway/internal/adminhttp"
|
||||
)
|
||||
|
||||
func newGatewayHTTPHandler() http.Handler {
|
||||
return adminhttp.NewGatewayHandler(adminhttp.GatewayHandlerOptions{
|
||||
AdminPath: adminStartup.AdminPath,
|
||||
AdminAPIPrefix: adminStartup.AdminAPIPrefix,
|
||||
APIHandler: newAdminAPIHandler(),
|
||||
WebSocketEnabled: config.WebSocket.Enable,
|
||||
WebSocketPath: normalizedWebSocketPath(),
|
||||
WebSocketHandler: handleWebSocket,
|
||||
})
|
||||
}
|
||||
542
cmd/gateway/admin_static/app.css
Normal file
542
cmd/gateway/admin_static/app.css
Normal file
@@ -0,0 +1,542 @@
|
||||
/* cmd/gateway/admin_static/app.css 定义嵌入式管理端仪表盘、表格、表单、对话框和响应式布局样式。 */
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f6f7f4;
|
||||
--panel: #ffffff;
|
||||
--text: #1c2623;
|
||||
--muted: #66716c;
|
||||
--line: #d9ded8;
|
||||
--accent: #1f7a5d;
|
||||
--accent-dark: #145944;
|
||||
--warn: #a66321;
|
||||
--danger: #b42318;
|
||||
--shadow: 0 16px 40px rgba(21, 32, 28, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 14px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 36px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
padding: 0 14px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--accent-dark);
|
||||
}
|
||||
|
||||
button.ghost,
|
||||
button.secondary {
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
button.ghost:hover,
|
||||
button.secondary:hover {
|
||||
border-color: #aeb8b2;
|
||||
background: #eef2ef;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
button.danger:hover {
|
||||
background: #8f1d15;
|
||||
}
|
||||
|
||||
button.link-button {
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--accent-dark);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
button.link-button:hover {
|
||||
background: transparent;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 7px 10px;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 220px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
label.inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
label.inline input {
|
||||
width: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.shell {
|
||||
width: min(1180px, calc(100vw - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 24px 0 40px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.topbar h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.topbar p {
|
||||
margin: 2px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.session {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
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;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.panel.compact {
|
||||
width: min(420px, 100%);
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
border: 1px solid #f0c36a;
|
||||
border-radius: 6px;
|
||||
background: #fff7df;
|
||||
color: #5f410c;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.status-grid,
|
||||
.service-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat,
|
||||
.service {
|
||||
min-width: 0;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.stat span,
|
||||
.service span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stat strong,
|
||||
.service strong {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin: 8px 0 16px;
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
border-radius: 6px 6px 0 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tabs button.active {
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line);
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.toolbar input {
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 720px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: #fafbf9;
|
||||
}
|
||||
|
||||
td {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
tr.selected td {
|
||||
background: #f0f7f3;
|
||||
}
|
||||
|
||||
.actions {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
border-radius: 999px;
|
||||
padding: 0 9px;
|
||||
background: #e8f3ee;
|
||||
color: var(--accent-dark);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.badge.off {
|
||||
background: #f4ece6;
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chip {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 5px 9px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.service {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.service form,
|
||||
.inline-form {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.file-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 36px;
|
||||
border-radius: 6px;
|
||||
padding: 0 14px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-button input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.plugin-detail {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.detail-header h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.detail-header p {
|
||||
margin: 2px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status-grid.dense {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.plugin-layout {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plugin-layout .panel {
|
||||
min-width: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.plugin-layout h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: 120px minmax(0, 1fr);
|
||||
gap: 8px 10px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.kv dt {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.kv dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.mini-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mini-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(90px, 0.8fr) minmax(160px, 1.5fr) minmax(100px, 1fr) auto auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.mini-row:last-child {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.log-output {
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #fbfcfb;
|
||||
padding: 10px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.inline-alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
dialog {
|
||||
width: min(460px, calc(100vw - 28px));
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
dialog::backdrop {
|
||||
background: rgba(20, 27, 24, 0.36);
|
||||
}
|
||||
|
||||
dialog form {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
dialog h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.status-grid,
|
||||
.service-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.status-grid.dense,
|
||||
.plugin-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.mini-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.detail-header,
|
||||
.toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.session {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar input {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.shell {
|
||||
width: min(100vw - 20px, 1180px);
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.status-grid,
|
||||
.service-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
239
cmd/gateway/admin_static/index.html
Normal file
239
cmd/gateway/admin_static/index.html
Normal file
@@ -0,0 +1,239 @@
|
||||
<!-- cmd/gateway/admin_static/index.html 提供嵌入式管理端 HTML 外壳,包含对话框、标签页和运行时 API 前缀注入。 -->
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>mc-gateway admin</title>
|
||||
<link rel="stylesheet" href="app.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>mc-gateway</h1>
|
||||
<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" data-i18n="logout">Logout</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="alert" class="alert hidden"></section>
|
||||
|
||||
<section id="setupView" class="auth-view hidden">
|
||||
<form id="setupForm" class="panel compact">
|
||||
<h2 data-i18n="initialAdmin">Initial admin</h2>
|
||||
<label>
|
||||
<span data-i18n="username">Username</span>
|
||||
<input name="username" autocomplete="username" value="admin">
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="password">Password</span>
|
||||
<input name="password" autocomplete="new-password" type="password" required>
|
||||
</label>
|
||||
<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 data-i18n="login">Login</h2>
|
||||
<label>
|
||||
<span data-i18n="username">Username</span>
|
||||
<input name="username" autocomplete="username" required>
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="password">Password</span>
|
||||
<input name="password" autocomplete="current-password" type="password" required>
|
||||
</label>
|
||||
<button type="submit" data-i18n="login">Login</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="appView" class="hidden">
|
||||
<section id="statusGrid" class="status-grid"></section>
|
||||
|
||||
<nav class="tabs">
|
||||
<button data-tab="routes" class="active" type="button" data-i18n="routes">Routes</button>
|
||||
<button data-tab="plugins" type="button" data-i18n="plugins">Plugins</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" data-i18n-placeholder="routeSearch">
|
||||
<button id="newRouteBtn" type="button" data-i18n="newRoute">New route</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="pluginsTab" class="tab-panel hidden">
|
||||
<div class="toolbar">
|
||||
<label class="file-button">
|
||||
<span data-i18n="uploadPlugin">Upload plugin</span>
|
||||
<input id="pluginUploadInput" type="file" accept=".mcgp">
|
||||
</label>
|
||||
<button id="refreshPluginsBtn" class="secondary" type="button" data-i18n="refresh">Refresh</button>
|
||||
</div>
|
||||
<div id="pluginServicePanel" class="plugin-service-panel"></div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="pluginID">Plugin ID</th>
|
||||
<th data-i18n="name">Name</th>
|
||||
<th data-i18n="version">Version</th>
|
||||
<th data-i18n="artifact">Artifact</th>
|
||||
<th data-i18n="runtime">Runtime</th>
|
||||
<th data-i18n="desired">Desired</th>
|
||||
<th data-i18n="active">Active</th>
|
||||
<th data-i18n="desiredArtifact">Desired artifact</th>
|
||||
<th data-i18n="extensions">Extensions</th>
|
||||
<th data-i18n="priority">Priority</th>
|
||||
<th data-i18n="restart">Restart</th>
|
||||
<th data-i18n="health">Health</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pluginsBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="pluginDetail" class="plugin-detail"></div>
|
||||
</section>
|
||||
|
||||
<section id="servicesTab" class="tab-panel hidden">
|
||||
<div id="servicesGrid" class="service-grid"></div>
|
||||
</section>
|
||||
|
||||
<section id="usersTab" class="tab-panel hidden">
|
||||
<div class="toolbar">
|
||||
<button id="newUserBtn" type="button" data-i18n="newUser">New user</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="metricsTab" class="tab-panel hidden">
|
||||
<div id="metricsGrid" class="status-grid"></div>
|
||||
<div class="panel">
|
||||
<h2 data-i18n="routeHits">Route hits</h2>
|
||||
<div id="routeHits" class="chips"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="auditTab" class="tab-panel hidden">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<dialog id="routeDialog">
|
||||
<form id="routeForm" method="dialog">
|
||||
<h2 data-i18n="route">Route</h2>
|
||||
<label>
|
||||
<span data-i18n="host">Host</span>
|
||||
<input name="host" required>
|
||||
</label>
|
||||
<label>
|
||||
<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>
|
||||
<span data-i18n="enabled">Enabled</span>
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="note">Note</span>
|
||||
<input name="note">
|
||||
</label>
|
||||
<div class="dialog-actions">
|
||||
<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 data-i18n="user">User</h2>
|
||||
<label>
|
||||
<span data-i18n="username">Username</span>
|
||||
<input name="username" required>
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="role">Role</span>
|
||||
<select name="role">
|
||||
<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>
|
||||
<span data-i18n="password">Password</span>
|
||||
<input name="password" type="password">
|
||||
</label>
|
||||
<label class="inline">
|
||||
<input name="disabled" type="checkbox">
|
||||
<span data-i18n="disabled">Disabled</span>
|
||||
</label>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" data-close data-i18n="cancel">Cancel</button>
|
||||
<button type="submit" data-i18n="save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="config.js"></script>
|
||||
<script type="module" src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
92
cmd/gateway/admin_user_handlers.go
Normal file
92
cmd/gateway/admin_user_handlers.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// cmd/gateway/admin_user_handlers.go 提供管理员维护管理账号的接口,包括创建、更新、禁用和列表查询。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tursom/mc-gateway/internal/adminhttp"
|
||||
)
|
||||
|
||||
func handleAdminUsersList(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireRole(w, r, adminRoleAdmin); !ok {
|
||||
return
|
||||
}
|
||||
users, err := listUsers(r.Context())
|
||||
if err != nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"users": users})
|
||||
}
|
||||
|
||||
func handleAdminUsersCreate(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := requireRole(w, r, adminRoleAdmin)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req adminhttp.CreateUserRequest
|
||||
if !adminhttp.DecodeJSONRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
err := createUser(r.Context(), session.Username, req.Username, req.Role, req.Password, req.Disabled)
|
||||
if err != nil {
|
||||
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "user_create", "user", req.Username, false, err.Error())
|
||||
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "user_create", "user", req.Username, true, "user created")
|
||||
adminhttp.WriteJSON(w, http.StatusCreated, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func handleAdminUserItem(w http.ResponseWriter, r *http.Request, rawUsername string) {
|
||||
session, ok := requireRole(w, r, adminRoleAdmin)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
username, err := adminhttp.PathSegment(rawUsername)
|
||||
if err != nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodPatch:
|
||||
var req adminhttp.PatchUserRequest
|
||||
if !adminhttp.DecodeJSONRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
err := patchUser(r.Context(), session.Username, username, req.Role, req.Disabled, req.Password)
|
||||
if err != nil {
|
||||
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "user_patch", "user", username, false, err.Error())
|
||||
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "user_patch", "user", username, true, "user updated")
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
case http.MethodDelete:
|
||||
err := deleteUser(r.Context(), username)
|
||||
if err != nil {
|
||||
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "user_delete", "user", username, false, err.Error())
|
||||
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "user_delete", "user", username, true, "user deleted")
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
default:
|
||||
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func handleAdminAuditLogs(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireRole(w, r, adminRoleAdmin); !ok {
|
||||
return
|
||||
}
|
||||
logs, err := listAuditLogs(r.Context())
|
||||
if err != nil {
|
||||
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"audit_logs": logs})
|
||||
}
|
||||
141
cmd/gateway/admin_users.go
Normal file
141
cmd/gateway/admin_users.go
Normal file
@@ -0,0 +1,141 @@
|
||||
// cmd/gateway/admin_users.go 初始化管理用户仓库,并在没有账号时创建首次初始化用户。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/tursom/mc-gateway/internal/adminuser"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
adminRoleAdmin = adminuser.RoleAdmin
|
||||
adminRoleMember = adminuser.RoleMember
|
||||
adminRoleGuest = adminuser.RoleGuest
|
||||
)
|
||||
|
||||
func ensureInitialAdminFromEnv(ctx context.Context, db *sql.DB, password string) error {
|
||||
if strings.TrimSpace(password) == "" {
|
||||
return nil
|
||||
}
|
||||
empty, err := usersTableEmpty(ctx, db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !empty {
|
||||
return nil
|
||||
}
|
||||
return createUser(ctx, "system", "admin", adminRoleAdmin, password, false)
|
||||
}
|
||||
|
||||
func usersTableEmpty(ctx context.Context, db *sql.DB) (bool, error) {
|
||||
return adminuser.NewRepository(db).TableEmpty(ctx)
|
||||
}
|
||||
|
||||
func createInitialAdmin(ctx context.Context, username, password string) error {
|
||||
empty, err := usersTableEmpty(ctx, adminDB)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !empty {
|
||||
return errors.New("initial admin has already been created")
|
||||
}
|
||||
return createUser(ctx, "setup", username, adminRoleAdmin, password, false)
|
||||
}
|
||||
|
||||
func createUser(ctx context.Context, actor, username, role, password string, disabled bool) error {
|
||||
username = strings.TrimSpace(username)
|
||||
if err := adminuser.ValidateUsername(username); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := adminuser.ValidateRole(role); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := adminuser.ValidatePassword(password); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hash, err := hashPassword(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = actor
|
||||
return adminuser.NewRepository(adminDB).Create(ctx, username, role, hash, disabled)
|
||||
}
|
||||
|
||||
func authenticateUser(ctx context.Context, username, password string) (adminuser.User, error) {
|
||||
user, hash, err := getUserWithHash(ctx, username)
|
||||
if err != nil {
|
||||
return adminuser.User{}, err
|
||||
}
|
||||
if user.Disabled {
|
||||
return adminuser.User{}, errors.New("user is disabled")
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
|
||||
return adminuser.User{}, errors.New("invalid username or password")
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func getUserWithHash(ctx context.Context, username string) (adminuser.User, string, error) {
|
||||
return adminuser.NewRepository(adminDB).GetWithHash(ctx, username)
|
||||
}
|
||||
|
||||
func listUsers(ctx context.Context) ([]adminuser.User, error) {
|
||||
return adminuser.NewRepository(adminDB).List(ctx)
|
||||
}
|
||||
|
||||
func patchUser(ctx context.Context, actor, username string, role *string, disabled *bool, password *string) error {
|
||||
username = strings.TrimSpace(username)
|
||||
if err := adminuser.ValidateUsername(username); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var passwordHashProvider func() (string, error)
|
||||
if password != nil {
|
||||
passwordValue := *password
|
||||
passwordHashProvider = func() (string, error) {
|
||||
if err := adminuser.ValidatePassword(passwordValue); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hashPassword(passwordValue)
|
||||
}
|
||||
}
|
||||
|
||||
result, err := adminuser.NewRepository(adminDB).Patch(ctx, username, adminuser.Patch{
|
||||
Role: role,
|
||||
Disabled: disabled,
|
||||
PasswordHashProvider: passwordHashProvider,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.InvalidateSessions {
|
||||
removeSessionsForUser(username)
|
||||
}
|
||||
_ = actor
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteUser(ctx context.Context, username string) error {
|
||||
username = strings.TrimSpace(username)
|
||||
if err := adminuser.ValidateUsername(username); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := adminuser.NewRepository(adminDB).Delete(ctx, username); err != nil {
|
||||
return err
|
||||
}
|
||||
removeSessionsForUser(username)
|
||||
return nil
|
||||
}
|
||||
|
||||
func hashPassword(password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
return string(hash), err
|
||||
}
|
||||
40
cmd/gateway/config.go
Normal file
40
cmd/gateway/config.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// cmd/gateway/config.go 加载静态网关配置,并与管理数据库提供的运行态状态组合使用。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/tursom/mc-gateway/internal/gatewayconfig"
|
||||
)
|
||||
|
||||
var (
|
||||
config gatewayconfig.Config
|
||||
configLoadLock sync.Mutex
|
||||
)
|
||||
|
||||
func loadConfig() error {
|
||||
configLoadLock.Lock()
|
||||
defer configLoadLock.Unlock()
|
||||
|
||||
if err := initializeGatewayRuntime(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := loadLogger(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
loadPlugins()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadPluginConfig(cfg map[string]any, pluginCfg any) error {
|
||||
log.Info().
|
||||
Any("config", cfg).
|
||||
Msg("Loading plugin config")
|
||||
|
||||
return gatewayconfig.DecodePluginConfig(cfg, pluginCfg)
|
||||
}
|
||||
203
cmd/gateway/config_test.go
Normal file
203
cmd/gateway/config_test.go
Normal file
@@ -0,0 +1,203 @@
|
||||
// cmd/gateway/config_test.go 包含用于约束 config 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadPluginConfig(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
type pluginConfig struct {
|
||||
Enable bool `toml:"enable"`
|
||||
Name string `toml:"name"`
|
||||
Count int `toml:"count"`
|
||||
}
|
||||
|
||||
var got pluginConfig
|
||||
err := loadPluginConfig(map[string]any{
|
||||
"enable": true,
|
||||
"name": "plugin-a",
|
||||
"count": 7,
|
||||
}, &got)
|
||||
if err != nil {
|
||||
t.Fatalf("loadPluginConfig() error = %v", err)
|
||||
}
|
||||
|
||||
want := pluginConfig{Enable: true, Name: "plugin-a", Count: 7}
|
||||
if got != want {
|
||||
t.Fatalf("loadPluginConfig() = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPluginConfigReturnsDecodeError(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
type pluginConfig struct {
|
||||
Count int `toml:"count"`
|
||||
}
|
||||
|
||||
var got pluginConfig
|
||||
if err := loadPluginConfig(map[string]any{"count": "not-an-int"}, &got); err == nil {
|
||||
t.Fatal("loadPluginConfig() error = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStartupConfigDefaultsAndEnv(t *testing.T) {
|
||||
cfg, err := parseStartupConfig(func(string) string { return "" })
|
||||
if err != nil {
|
||||
t.Fatalf("parseStartupConfig() error = %v", err)
|
||||
}
|
||||
if cfg.DBPath != defaultAdminDBPath {
|
||||
t.Fatalf("DBPath = %q, want %q", cfg.DBPath, defaultAdminDBPath)
|
||||
}
|
||||
if cfg.TCPAdminPort != defaultTCPPort {
|
||||
t.Fatalf("TCPAdminPort = %d, want %d", cfg.TCPAdminPort, defaultTCPPort)
|
||||
}
|
||||
if cfg.AdminPath != defaultAdminPath {
|
||||
t.Fatalf("AdminPath = %q, want %q", cfg.AdminPath, defaultAdminPath)
|
||||
}
|
||||
if cfg.AdminAPIPrefix != defaultAdminAPIPrefix {
|
||||
t.Fatalf("AdminAPIPrefix = %q, want %q", cfg.AdminAPIPrefix, defaultAdminAPIPrefix)
|
||||
}
|
||||
|
||||
env := map[string]string{
|
||||
adminEnvDB: "/tmp/mc.db",
|
||||
adminEnvTCPAdminPort: "25575",
|
||||
adminEnvPath: "/ops",
|
||||
adminEnvAPIPrefix: "/ops/api/",
|
||||
}
|
||||
cfg, err = parseStartupConfig(func(key string) string { return env[key] })
|
||||
if err != nil {
|
||||
t.Fatalf("parseStartupConfig(env) error = %v", err)
|
||||
}
|
||||
if cfg.DBPath != "/tmp/mc.db" || cfg.TCPAdminPort != 25575 || cfg.AdminPath != "/ops/" || cfg.AdminAPIPrefix != "/ops/api" {
|
||||
t.Fatalf("startup config = %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStartupConfigReturnsErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
}{
|
||||
{
|
||||
name: "invalid port",
|
||||
env: map[string]string{adminEnvTCPAdminPort: "70000"},
|
||||
},
|
||||
{
|
||||
name: "invalid admin path",
|
||||
env: map[string]string{adminEnvPath: "admin"},
|
||||
},
|
||||
{
|
||||
name: "invalid api prefix",
|
||||
env: map[string]string{adminEnvAPIPrefix: "api"},
|
||||
},
|
||||
{
|
||||
name: "api prefix equals admin path",
|
||||
env: map[string]string{
|
||||
adminEnvPath: "/admin",
|
||||
adminEnvAPIPrefix: "/admin",
|
||||
},
|
||||
},
|
||||
{
|
||||
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"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := parseStartupConfig(func(key string) string { return tt.env[key] })
|
||||
if err == nil {
|
||||
t.Fatal("parseStartupConfig() error = nil, want error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigInitializesSQLiteDefaults(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "gateway.sqlite3")
|
||||
t.Setenv(adminEnvDB, dbPath)
|
||||
t.Setenv(adminEnvTCPAdminPort, "25575")
|
||||
t.Setenv(adminEnvPath, "/ops")
|
||||
t.Setenv(adminEnvAPIPrefix, "/ops/api")
|
||||
|
||||
if err := loadConfig(); err != nil {
|
||||
t.Fatalf("loadConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if !config.Tcp.Enable || config.Tcp.Port != 25575 {
|
||||
t.Fatalf("tcp config = %+v", config.Tcp)
|
||||
}
|
||||
if config.Quic.Enable || config.Kcp.Enable || config.WebSocket.Enable {
|
||||
t.Fatalf("optional services should be disabled by default: quic=%+v kcp=%+v websocket=%+v", config.Quic, config.Kcp, config.WebSocket)
|
||||
}
|
||||
if config.Kcp.Port != defaultKCPPort || config.Kcp.DataShards != defaultKCPDataShards || config.Kcp.ParityShards != defaultKCPParityShards {
|
||||
t.Fatalf("kcp config = %+v", config.Kcp)
|
||||
}
|
||||
if adminStartup.DBPath != dbPath || adminStartup.AdminPath != "/ops/" || adminStartup.AdminAPIPrefix != "/ops/api" {
|
||||
t.Fatalf("adminStartup = %+v", adminStartup)
|
||||
}
|
||||
if adminDB == nil {
|
||||
t.Fatal("adminDB = nil")
|
||||
}
|
||||
|
||||
var enabled, port int
|
||||
if err := adminDB.QueryRow(`SELECT enabled, port FROM services WHERE name = ?`, serviceNameTCPAdmin).Scan(&enabled, &port); err != nil {
|
||||
t.Fatalf("query tcp_admin service: %v", err)
|
||||
}
|
||||
if enabled != 1 || port != 25575 {
|
||||
t.Fatalf("tcp_admin service enabled=%d port=%d, want enabled=1 port=25575", enabled, port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigCreatesInitialAdminFromEnv(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
t.Setenv(adminEnvDB, filepath.Join(t.TempDir(), "gateway.sqlite3"))
|
||||
t.Setenv(adminEnvInitialPassword, "secret")
|
||||
|
||||
if err := loadConfig(); err != nil {
|
||||
t.Fatalf("loadConfig() error = %v", err)
|
||||
}
|
||||
|
||||
user, err := authenticateUser(t.Context(), "admin", "secret")
|
||||
if err != nil {
|
||||
t.Fatalf("authenticateUser() error = %v", err)
|
||||
}
|
||||
if user.Role != adminRoleAdmin {
|
||||
t.Fatalf("admin role = %q, want %q", user.Role, adminRoleAdmin)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigReturnsEnvErrors(t *testing.T) {
|
||||
t.Run("invalid port", func(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
t.Setenv(adminEnvDB, filepath.Join(t.TempDir(), "gateway.sqlite3"))
|
||||
t.Setenv(adminEnvTCPAdminPort, "0")
|
||||
if err := loadConfig(); err == nil {
|
||||
t.Fatal("loadConfig() error = nil, want error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid admin path", func(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
t.Setenv(adminEnvDB, filepath.Join(t.TempDir(), "gateway.sqlite3"))
|
||||
t.Setenv(adminEnvPath, "admin")
|
||||
if err := loadConfig(); err == nil {
|
||||
t.Fatal("loadConfig() error = nil, want error")
|
||||
}
|
||||
})
|
||||
}
|
||||
9
cmd/gateway/err.go
Normal file
9
cmd/gateway/err.go
Normal file
@@ -0,0 +1,9 @@
|
||||
// cmd/gateway/err.go 集中放置网关请求路径使用的少量哨兵错误。
|
||||
|
||||
package main
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
errEmptyBuffer = errors.New("empty buffer")
|
||||
)
|
||||
385
cmd/gateway/handle_request_test.go
Normal file
385
cmd/gateway/handle_request_test.go
Normal file
@@ -0,0 +1,385 @@
|
||||
// cmd/gateway/handle_request_test.go 包含用于约束 handle request 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tursom/mc-gateway/internal/pluginmanager"
|
||||
"github.com/tursom/mc-gateway/plugin/api"
|
||||
"github.com/tursom/mc-gateway/protocol"
|
||||
)
|
||||
|
||||
func TestHandleRequestProxiesAndClosesConnections(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
packet := gatewayTestPacket("play.example")
|
||||
source := newGatewayTestConn(packet)
|
||||
upstream := newGatewayTestConn([]byte("reply"))
|
||||
setGatewayTestRoutes(map[string]string{
|
||||
"play.example": "backend.example:25565",
|
||||
})
|
||||
|
||||
registerGatewayUpstreamHook(
|
||||
t,
|
||||
func(net.Conn, string) bool { return true },
|
||||
func(net.Conn, string) (net.Conn, error) {
|
||||
return upstream, nil
|
||||
},
|
||||
)
|
||||
|
||||
handleRequest(source)
|
||||
|
||||
if !source.closed {
|
||||
t.Fatal("source connection was not closed")
|
||||
}
|
||||
if !upstream.closed {
|
||||
t.Fatal("upstream connection was not closed")
|
||||
}
|
||||
if !bytes.Equal(upstream.writeBuf.Bytes(), packet) {
|
||||
t.Fatalf("upstream initial packet = %v, want %v", upstream.writeBuf.Bytes(), packet)
|
||||
}
|
||||
if got := source.writeBuf.String(); got != "reply" {
|
||||
t.Fatalf("proxied reply = %q, want reply", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestProtocolProxyReplaysInitialDataOnce(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
packet := gatewayTestPacket("play.example", 0x63, 0x02)
|
||||
source := newGatewayTestConn(packet)
|
||||
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.ServerHost != "play.example" || req.Host != "play.example" {
|
||||
t.Fatalf("request host = %q/%q, want play.example", req.ServerHost, req.Host)
|
||||
}
|
||||
if req.ProtocolVersion != 0x63 || req.NextState != 0x02 {
|
||||
t.Fatalf("protocol/next state = %d/%d, want 99/2", req.ProtocolVersion, req.NextState)
|
||||
}
|
||||
if !bytes.Equal(req.InitialData, packet) {
|
||||
t.Fatalf("initial data = %v, want %v", req.InitialData, packet)
|
||||
}
|
||||
gatewayEnd, pluginEnd := net.Pipe()
|
||||
go func() {
|
||||
defer pluginEnd.Close()
|
||||
buf := make([]byte, len(packet))
|
||||
if _, err := io.ReadFull(pluginEnd, buf); err != nil {
|
||||
t.Errorf("plugin endpoint ReadFull() error = %v", err)
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(buf, packet) {
|
||||
t.Errorf("plugin endpoint initial data = %v, want %v", buf, packet)
|
||||
return
|
||||
}
|
||||
if host := protocol.GetMcHost(buf); host != "play.example" {
|
||||
t.Errorf("plugin endpoint host = %q, want play.example", host)
|
||||
return
|
||||
}
|
||||
_, _ = pluginEnd.Write([]byte("login rejected"))
|
||||
}()
|
||||
return gatewayEnd, nil
|
||||
}},
|
||||
})
|
||||
artifact := uploadGatewayTestArtifactWithCapabilities(t, pluginsManager, "proxy-plugin", gatewayProtocolProxyCapabilities())
|
||||
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "proxy-plugin", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
approveGatewayPluginGovernanceForTest(t, "proxy-plugin", artifact.ID)
|
||||
if _, err := pluginsManager.Enable(context.Background(), "admin", "proxy-plugin"); err != nil {
|
||||
t.Fatalf("Enable() error = %v", err)
|
||||
}
|
||||
|
||||
handleRequest(source)
|
||||
|
||||
if got := source.writeBuf.String(); got != "login rejected" {
|
||||
t.Fatalf("source response = %q, want login rejected", got)
|
||||
}
|
||||
plan := pluginsManager.DispatchPlan(context.Background())
|
||||
if len(plan.Handlers) != 1 {
|
||||
t.Fatalf("dispatch handlers = %d, want 1", len(plan.Handlers))
|
||||
}
|
||||
if plan.Handlers[0].Mode != pluginmanager.UpstreamModeProtocolProxy {
|
||||
t.Fatalf("handler mode = %q, want protocol-proxy", plan.Handlers[0].Mode)
|
||||
}
|
||||
if plan.Handlers[0].ProxyStarted != 1 || plan.Handlers[0].ProxyCompleted != 1 {
|
||||
t.Fatalf("proxy lifecycle = started %d completed %d, want 1/1", plan.Handlers[0].ProxyStarted, plan.Handlers[0].ProxyCompleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestManagedErrBlockedClosesSource(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
source := newGatewayTestConn(gatewayTestPacket("play.example"))
|
||||
setGatewayTestRoutes(map[string]string{
|
||||
"play.example": "backend.example:25565",
|
||||
})
|
||||
pluginsManager = pluginmanager.New(pluginmanager.Options{
|
||||
DB: newGatewayTestPluginDB(t),
|
||||
ArtifactRoot: t.TempDir(),
|
||||
Adapter: gatewayTestPluginAdapter{handler: func(api.UpstreamConnectRequest) (net.Conn, error) {
|
||||
return nil, api.ErrBlocked
|
||||
}},
|
||||
})
|
||||
artifact := uploadGatewayTestArtifact(t, pluginsManager, "blocked-plugin")
|
||||
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "blocked-plugin", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
if _, err := pluginsManager.Enable(context.Background(), "admin", "blocked-plugin"); err != nil {
|
||||
t.Fatalf("Enable() error = %v", err)
|
||||
}
|
||||
|
||||
handleRequest(source)
|
||||
|
||||
if !source.closed {
|
||||
t.Fatal("source was not closed")
|
||||
}
|
||||
if source.writeBuf.Len() != 0 {
|
||||
t.Fatalf("source response length = %d, want 0", source.writeBuf.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestProtocolProxyPanicOnlyFailsCurrentConnection(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
setGatewayTestRoutes(map[string]string{
|
||||
"play.example": "backend.example:25565",
|
||||
})
|
||||
calls := 0
|
||||
pluginsManager = pluginmanager.New(pluginmanager.Options{
|
||||
DB: newGatewayTestPluginDB(t),
|
||||
ArtifactRoot: t.TempDir(),
|
||||
Adapter: gatewayTestPluginAdapter{handler: func(api.UpstreamConnectRequest) (net.Conn, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
panic("boom")
|
||||
}
|
||||
upstream := newGatewayTestConn(nil)
|
||||
upstream.writeBuf.WriteString("ok")
|
||||
return upstream, nil
|
||||
}},
|
||||
})
|
||||
artifact := uploadGatewayTestArtifact(t, pluginsManager, "panic-plugin")
|
||||
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "panic-plugin", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
if _, err := pluginsManager.Enable(context.Background(), "admin", "panic-plugin"); err != nil {
|
||||
t.Fatalf("Enable() error = %v", err)
|
||||
}
|
||||
|
||||
first := newGatewayTestConn(gatewayTestPacket("play.example"))
|
||||
handleRequest(first)
|
||||
if !first.closed {
|
||||
t.Fatal("first connection was not closed")
|
||||
}
|
||||
|
||||
second := newGatewayTestConn(gatewayTestPacket("play.example"))
|
||||
handleRequest(second)
|
||||
if !second.closed {
|
||||
t.Fatal("second connection was not closed")
|
||||
}
|
||||
plan := pluginsManager.DispatchPlan(context.Background())
|
||||
if got := plan.Handlers[0].Panics; got != 1 {
|
||||
t.Fatalf("panic count = %d, want 1", got)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("handler calls = %d, want 2", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestProtocolProxyDisableSkipsNewConnections(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
setGatewayTestRoutes(map[string]string{
|
||||
"play.example": "backend.example:25565",
|
||||
})
|
||||
proxyCalls := 0
|
||||
pluginsManager = pluginmanager.New(pluginmanager.Options{
|
||||
DB: newGatewayTestPluginDB(t),
|
||||
ArtifactRoot: t.TempDir(),
|
||||
Adapter: gatewayTestPluginAdapter{handler: func(req api.UpstreamConnectRequest) (net.Conn, error) {
|
||||
proxyCalls++
|
||||
gatewayEnd, pluginEnd := net.Pipe()
|
||||
initialLen := len(req.InitialData)
|
||||
go func() {
|
||||
defer pluginEnd.Close()
|
||||
_, _ = io.ReadFull(pluginEnd, make([]byte, initialLen))
|
||||
}()
|
||||
return gatewayEnd, nil
|
||||
}},
|
||||
})
|
||||
artifact := uploadGatewayTestArtifactWithCapabilities(t, pluginsManager, "proxy-plugin", gatewayProtocolProxyCapabilities())
|
||||
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "proxy-plugin", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
approveGatewayPluginGovernanceForTest(t, "proxy-plugin", artifact.ID)
|
||||
if _, err := pluginsManager.Enable(context.Background(), "admin", "proxy-plugin"); err != nil {
|
||||
t.Fatalf("Enable() error = %v", err)
|
||||
}
|
||||
|
||||
first := newGatewayTestConn(gatewayTestPacket("play.example"))
|
||||
handleRequest(first)
|
||||
if proxyCalls != 1 {
|
||||
t.Fatalf("proxy calls after first request = %d, want 1", proxyCalls)
|
||||
}
|
||||
if _, err := pluginsManager.Disable(context.Background(), "admin", "proxy-plugin"); err != nil {
|
||||
t.Fatalf("Disable() error = %v", err)
|
||||
}
|
||||
|
||||
legacyUpstream := newGatewayTestConn(nil)
|
||||
registerGatewayUpstreamHook(
|
||||
t,
|
||||
func(net.Conn, string) bool { return true },
|
||||
func(net.Conn, string) (net.Conn, error) { return legacyUpstream, nil },
|
||||
)
|
||||
second := newGatewayTestConn(gatewayTestPacket("play.example"))
|
||||
handleRequest(second)
|
||||
if proxyCalls != 1 {
|
||||
t.Fatalf("proxy calls after disable = %d, want still 1", proxyCalls)
|
||||
}
|
||||
if legacyUpstream.writeBuf.Len() == 0 {
|
||||
t.Fatal("legacy upstream did not receive second request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestRouteResolverUsesOverrideAndSQLiteFallback(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
setGatewayTestRoutes(map[string]string{"fallback.example": "fallback-upstream:25565"})
|
||||
var dialed []string
|
||||
pluginsManager = pluginmanager.New(pluginmanager.Options{
|
||||
DB: newGatewayTestPluginDB(t),
|
||||
ArtifactRoot: t.TempDir(),
|
||||
Adapter: gatewayTestPluginAdapter{initHook: func(gateway *pluginmanager.Gateway) error {
|
||||
return api.RegisterHookHandler(gateway, api.HookRouteResolve,
|
||||
func(api.RouteResolveRequest) bool { return true },
|
||||
func(req api.RouteResolveRequest) (api.RouteDecision, error) {
|
||||
if req.Host == "override.example" {
|
||||
return api.RouteDecision{Action: api.RouteDecisionOverride, Upstream: "override-upstream:25565", CacheTTL: time.Minute}, nil
|
||||
}
|
||||
return api.RouteDecision{Action: api.RouteDecisionPass}, nil
|
||||
})
|
||||
}},
|
||||
})
|
||||
artifact := uploadGatewayTestArtifactWithManifest(t, pluginsManager, "route-plugin", func(manifest *pluginmanager.Manifest) {
|
||||
manifest.ExtensionPoints = []pluginmanager.ExtensionPoint{{Type: "provider", Key: pluginmanager.ExtensionRouteResolve}}
|
||||
manifest.Capabilities = json.RawMessage(`{"extension_points":["route.resolve/v1"]}`)
|
||||
})
|
||||
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "route-plugin", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
if _, err := pluginsManager.Enable(context.Background(), "admin", "route-plugin"); err != nil {
|
||||
t.Fatalf("Enable() error = %v", err)
|
||||
}
|
||||
registerGatewayUpstreamHook(t, func(net.Conn, string) bool { return true }, func(_ net.Conn, host string) (net.Conn, error) {
|
||||
dialed = append(dialed, host)
|
||||
return newGatewayTestConn(nil), nil
|
||||
})
|
||||
|
||||
handleRequest(newGatewayTestConn(gatewayTestPacket("override.example")))
|
||||
handleRequest(newGatewayTestConn(gatewayTestPacket("fallback.example")))
|
||||
|
||||
if len(dialed) != 2 || dialed[0] != "override-upstream:25565" || dialed[1] != "fallback-upstream:25565" {
|
||||
t.Fatalf("dialed = %+v, want override then sqlite fallback", dialed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestStatusPingPluginRespondsPerHost(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
pluginsManager = pluginmanager.New(pluginmanager.Options{
|
||||
DB: newGatewayTestPluginDB(t),
|
||||
ArtifactRoot: t.TempDir(),
|
||||
Adapter: gatewayTestPluginAdapter{initHook: func(gateway *pluginmanager.Gateway) error {
|
||||
return api.RegisterHookHandler(gateway, api.HookStatusPing,
|
||||
func(api.StatusPingRequest) bool { return true },
|
||||
func(req api.StatusPingRequest) (api.StatusPingResponse, error) {
|
||||
return api.StatusPingResponse{MOTD: "hello " + req.Host, VersionText: "phase7", MaxPlayers: 100}, nil
|
||||
})
|
||||
}},
|
||||
})
|
||||
artifact := uploadGatewayTestArtifactWithManifest(t, pluginsManager, "status-plugin", func(manifest *pluginmanager.Manifest) {
|
||||
manifest.ExtensionPoints = []pluginmanager.ExtensionPoint{{Type: "hook", Key: pluginmanager.ExtensionStatusPing}}
|
||||
manifest.Capabilities = json.RawMessage(`{"extension_points":["status.ping/v1"]}`)
|
||||
})
|
||||
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "status-plugin", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil {
|
||||
t.Fatalf("SetDesired() error = %v", err)
|
||||
}
|
||||
if _, err := pluginsManager.Enable(context.Background(), "admin", "status-plugin"); err != nil {
|
||||
t.Fatalf("Enable() error = %v", err)
|
||||
}
|
||||
|
||||
source := newGatewayTestConn(gatewayTestPacket("status.example", 0x63, 0x01))
|
||||
handleRequest(source)
|
||||
|
||||
if got := source.writeBuf.String(); !strings.Contains(got, "hello status.example") {
|
||||
t.Fatalf("status response = %q, want host MOTD", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestRecoversAndClosesConnection(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
source := &panicReadGatewayConn{gatewayTestConn: newGatewayTestConn(nil)}
|
||||
|
||||
handleRequest(source)
|
||||
|
||||
if !source.closed {
|
||||
t.Fatal("source connection was not closed after panic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayHandleConnStartsRequestGoroutine(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
source := newGatewayTestConn(nil)
|
||||
source.readErr = errors.New("read failed")
|
||||
|
||||
(&Gateway{}).HandleConn(source)
|
||||
|
||||
deadline := time.After(2 * time.Second)
|
||||
ticker := time.NewTicker(time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatal("timed out waiting for HandleConn goroutine")
|
||||
case <-ticker.C:
|
||||
if source.isClosed() {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayTestOpPanics(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("TestOp() did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
(&Gateway{}).TestOp()
|
||||
}
|
||||
|
||||
type panicReadGatewayConn struct {
|
||||
*gatewayTestConn
|
||||
}
|
||||
|
||||
func (c *panicReadGatewayConn) Read([]byte) (int, error) {
|
||||
panic("read panic")
|
||||
}
|
||||
59
cmd/gateway/haproxy.go
Normal file
59
cmd/gateway/haproxy.go
Normal file
@@ -0,0 +1,59 @@
|
||||
// cmd/gateway/haproxy.go 为需要 HAProxy PROXY 头的上游 TCP 连接先写入代理头,再回放 Minecraft 流量。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
proxyproto "github.com/pires/go-proxyproto"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func haProxyUpstream(source net.Conn, host string) net.Conn {
|
||||
target, err := net.ResolveTCPAddr("tcp", host)
|
||||
if err != nil {
|
||||
gatewayMetrics.UpstreamDialError()
|
||||
log.Err(err).Msg("failed to resolve TCP address")
|
||||
return nil
|
||||
}
|
||||
|
||||
conn, err := tcpDialer.Dial("tcp", target.String())
|
||||
if err != nil {
|
||||
gatewayMetrics.UpstreamDialError()
|
||||
log.Err(err).Msg("failed to dial TCP")
|
||||
return nil
|
||||
}
|
||||
setSocketOptions(conn)
|
||||
|
||||
sourceAddr, err := net.ResolveTCPAddr(
|
||||
source.RemoteAddr().Network(),
|
||||
source.RemoteAddr().String(),
|
||||
)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to resolve TCP address")
|
||||
conn.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
TransportProtocol := proxyproto.TCPv4
|
||||
if sourceAddr.IP.To4() == nil {
|
||||
TransportProtocol = proxyproto.TCPv6
|
||||
}
|
||||
|
||||
header := &proxyproto.Header{
|
||||
Version: 1,
|
||||
Command: proxyproto.PROXY,
|
||||
TransportProtocol: TransportProtocol,
|
||||
SourceAddr: sourceAddr,
|
||||
DestinationAddr: target,
|
||||
}
|
||||
// 连接建立后先写入 PROXY 头,再转发 Minecraft 首包。
|
||||
_, err = header.WriteTo(conn)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to write proxy header")
|
||||
conn.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
return conn
|
||||
}
|
||||
70
cmd/gateway/haproxy_test.go
Normal file
70
cmd/gateway/haproxy_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// cmd/gateway/haproxy_test.go 包含用于约束 haproxy 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHaProxyUpstreamWritesProxyHeader(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("Listen() error = %v", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
headerCh := make(chan string, 1)
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
header, err := bufio.NewReader(conn).ReadString('\n')
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
headerCh <- header
|
||||
}()
|
||||
|
||||
source := newGatewayTestConn(nil)
|
||||
source.remote = &net.TCPAddr{IP: net.ParseIP("127.0.0.2"), Port: 45678}
|
||||
|
||||
conn := haProxyUpstream(source, listener.Addr().String())
|
||||
if conn == nil {
|
||||
t.Fatal("haProxyUpstream() = nil, want connection")
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
select {
|
||||
case header := <-headerCh:
|
||||
if !strings.HasPrefix(header, "PROXY TCP4 127.0.0.2 127.0.0.1 45678 ") {
|
||||
t.Fatalf("proxy header = %q", header)
|
||||
}
|
||||
if !strings.HasSuffix(header, "\r\n") {
|
||||
t.Fatalf("proxy header missing CRLF: %q", header)
|
||||
}
|
||||
case err := <-errCh:
|
||||
t.Fatalf("accept/read header error = %v", err)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for proxy header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHaProxyUpstreamReturnsNilForInvalidHost(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
if got := haProxyUpstream(newGatewayTestConn(nil), "not a tcp address"); got != nil {
|
||||
t.Fatalf("haProxyUpstream() = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
69
cmd/gateway/kcp.go
Normal file
69
cmd/gateway/kcp.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// cmd/gateway/kcp.go 启动可选的 KCP 监听器,并把接收到的会话转入统一网关请求处理流程。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/xtaci/kcp-go"
|
||||
)
|
||||
|
||||
func runKcp(wg *sync.WaitGroup) {
|
||||
if wg != nil {
|
||||
defer wg.Done()
|
||||
}
|
||||
|
||||
// KCP 监听使用运行态服务配置中的分片参数,和上游拨号保持一致。
|
||||
listener, err := kcp.ListenWithOptions(fmt.Sprintf(":%d", config.Kcp.Port), nil, config.Kcp.DataShards, config.Kcp.ParityShards)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).
|
||||
Int("port", config.Kcp.Port).
|
||||
Msg("Failed to listen on KCP port")
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
log.Info().Int("port", config.Kcp.Port).Msg("KCP server is listening")
|
||||
|
||||
for {
|
||||
conn, err := listener.AcceptKCP()
|
||||
if err != nil {
|
||||
log.Err(err).
|
||||
Msg("Failed to accept KCP connection")
|
||||
continue
|
||||
}
|
||||
log.Debug().
|
||||
Str("remote_addr", conn.RemoteAddr().String()).
|
||||
Msg("Accepted KCP connection")
|
||||
|
||||
tuneKcpConn(conn)
|
||||
|
||||
// KCP session 实现 net.Conn,可以直接进入统一网关请求流程。
|
||||
go handleRequest(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func upstreamKcp(host string) net.Conn {
|
||||
// KCP 上游使用与入口相同的 data/parity shards,确保两端编码参数匹配。
|
||||
conn, err := kcp.DialWithOptions(host, nil, config.Kcp.DataShards, config.Kcp.ParityShards)
|
||||
if err != nil {
|
||||
gatewayMetrics.UpstreamDialError()
|
||||
log.Error().Err(err).
|
||||
Msg("Failed to dial KCP server")
|
||||
return nil
|
||||
}
|
||||
|
||||
tuneKcpConn(conn)
|
||||
return conn
|
||||
}
|
||||
|
||||
func tuneKcpConn(conn *kcp.UDPSession) {
|
||||
// 这里偏向低延迟交互:stream mode 模拟 TCP 字节流,禁用写延迟并打开快速 ACK。
|
||||
conn.SetStreamMode(true)
|
||||
conn.SetWriteDelay(false)
|
||||
conn.SetNoDelay(1, 10, 2, 1)
|
||||
conn.SetWindowSize(256, 256)
|
||||
conn.SetACKNoDelay(true)
|
||||
}
|
||||
66
cmd/gateway/log.go
Normal file
66
cmd/gateway/log.go
Normal file
@@ -0,0 +1,66 @@
|
||||
// cmd/gateway/log.go 配置网关日志、日志文件、日志级别和日志轮转钩子。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
var (
|
||||
currentLogFile string
|
||||
)
|
||||
|
||||
func loadLogger() error {
|
||||
if len(config.Log.Level) == 0 {
|
||||
config.Log.Level = "info"
|
||||
}
|
||||
|
||||
level, err := zerolog.ParseLevel(config.Log.Level)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Logger = log.Logger.Level(level)
|
||||
|
||||
if config.Log.File != "" && currentLogFile != config.Log.File {
|
||||
if err := os.MkdirAll(filepath.Dir(config.Log.File), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logFile, err := os.OpenFile(config.Log.File, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Logger = log.Logger.Output(io.MultiWriter(os.Stdout, logFile))
|
||||
|
||||
currentLogFile = config.Log.File
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func reopenLogFile() error {
|
||||
configLoadLock.Lock()
|
||||
defer configLoadLock.Unlock()
|
||||
|
||||
if config.Log.File == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 重新打开日志文件
|
||||
logFile, err := os.OpenFile(config.Log.File, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Logger = log.Logger.Output(io.MultiWriter(os.Stdout, logFile))
|
||||
currentLogFile = config.Log.File
|
||||
|
||||
return nil
|
||||
}
|
||||
17
cmd/gateway/log_notunix.go
Normal file
17
cmd/gateway/log_notunix.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// cmd/gateway/log_notunix.go 在没有 Unix 信号的平台上提供空的日志轮转信号钩子。
|
||||
|
||||
// pid_unix.go
|
||||
//go:build !unix && !plan9
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func handleLogRotate() {
|
||||
// 非 Unix 平台不执行日志轮转信号处理。
|
||||
// 该平台不支持通过信号触发日志轮转。
|
||||
// 保留空实现是为了让跨平台调用点保持一致。
|
||||
log.Info().Msg("Log rotation is not supported on this platform")
|
||||
}
|
||||
139
cmd/gateway/log_pid_test.go
Normal file
139
cmd/gateway/log_pid_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
// cmd/gateway/log_pid_test.go 包含用于约束 log pid 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func TestWritePIDFileCreatesAndSwitchesFiles(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
firstPID := filepath.Join(tmpDir, "first.pid")
|
||||
secondPID := filepath.Join(tmpDir, "second.pid")
|
||||
|
||||
config.PidFile = firstPID
|
||||
if err := writePIDFile(); err != nil {
|
||||
t.Fatalf("writePIDFile(first) error = %v", err)
|
||||
}
|
||||
assertPIDFile(t, firstPID)
|
||||
|
||||
config.PidFile = secondPID
|
||||
if err := writePIDFile(); err != nil {
|
||||
t.Fatalf("writePIDFile(second) error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(firstPID); !os.IsNotExist(err) {
|
||||
t.Fatalf("first pid file still exists or stat failed: %v", err)
|
||||
}
|
||||
assertPIDFile(t, secondPID)
|
||||
|
||||
removePIDFile()
|
||||
if _, err := os.Stat(secondPID); !os.IsNotExist(err) {
|
||||
t.Fatalf("second pid file still exists or stat failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePIDFileNoopsWhenPathUnchanged(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
pidPath := filepath.Join(t.TempDir(), "gateway.pid")
|
||||
config.PidFile = pidPath
|
||||
if err := writePIDFile(); err != nil {
|
||||
t.Fatalf("writePIDFile() error = %v", err)
|
||||
}
|
||||
firstStat, err := os.Stat(pidPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Stat(first) error = %v", err)
|
||||
}
|
||||
|
||||
if err := writePIDFile(); err != nil {
|
||||
t.Fatalf("writePIDFile() second error = %v", err)
|
||||
}
|
||||
secondStat, err := os.Stat(pidPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Stat(second) error = %v", err)
|
||||
}
|
||||
if !firstStat.ModTime().Equal(secondStat.ModTime()) {
|
||||
t.Fatalf("pid file mod time changed: %v -> %v", firstStat.ModTime(), secondStat.ModTime())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadLoggerDefaultLevelAndInvalidLevel(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
if err := loadLogger(); err != nil {
|
||||
t.Fatalf("loadLogger() error = %v", err)
|
||||
}
|
||||
if config.Log.Level != "info" {
|
||||
t.Fatalf("default log level = %q, want info", config.Log.Level)
|
||||
}
|
||||
if got := log.Logger.GetLevel(); got != zerolog.InfoLevel {
|
||||
t.Fatalf("logger level = %v, want %v", got, zerolog.InfoLevel)
|
||||
}
|
||||
|
||||
config.Log.Level = "not-a-level"
|
||||
if err := loadLogger(); err == nil {
|
||||
t.Fatal("loadLogger() error = nil, want invalid level error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadLoggerCreatesLogFile(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
logPath := filepath.Join(t.TempDir(), "nested", "gateway.log")
|
||||
config.Log.Level = "debug"
|
||||
config.Log.File = logPath
|
||||
|
||||
if err := loadLogger(); err != nil {
|
||||
t.Fatalf("loadLogger() error = %v", err)
|
||||
}
|
||||
if currentLogFile != logPath {
|
||||
t.Fatalf("currentLogFile = %q, want %q", currentLogFile, logPath)
|
||||
}
|
||||
if _, err := os.Stat(logPath); err != nil {
|
||||
t.Fatalf("Stat(log file) error = %v", err)
|
||||
}
|
||||
if got := log.Logger.GetLevel(); got != zerolog.DebugLevel {
|
||||
t.Fatalf("logger level = %v, want %v", got, zerolog.DebugLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReopenLogFile(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
if err := reopenLogFile(); err != nil {
|
||||
t.Fatalf("reopenLogFile(empty) error = %v", err)
|
||||
}
|
||||
|
||||
logPath := filepath.Join(t.TempDir(), "gateway.log")
|
||||
config.Log.File = logPath
|
||||
if err := reopenLogFile(); err != nil {
|
||||
t.Fatalf("reopenLogFile() error = %v", err)
|
||||
}
|
||||
if currentLogFile != logPath {
|
||||
t.Fatalf("currentLogFile = %q, want %q", currentLogFile, logPath)
|
||||
}
|
||||
if _, err := os.Stat(logPath); err != nil {
|
||||
t.Fatalf("Stat(log file) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertPIDFile(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", path, err)
|
||||
}
|
||||
want := fmt.Sprintf("%d\n", os.Getpid())
|
||||
if string(got) != want {
|
||||
t.Fatalf("pid file %s = %q, want %q", path, string(got), want)
|
||||
}
|
||||
}
|
||||
26
cmd/gateway/log_unix.go
Normal file
26
cmd/gateway/log_unix.go
Normal file
@@ -0,0 +1,26 @@
|
||||
// cmd/gateway/log_unix.go 注册 Unix 信号处理,让进程无需完整重启即可重新打开日志文件。
|
||||
|
||||
// pid_unix.go
|
||||
//go:build unix || plan9
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func handleLogRotate() {
|
||||
signalChan := make(chan os.Signal, 1)
|
||||
signal.Notify(signalChan, syscall.SIGHUP)
|
||||
|
||||
for range signalChan {
|
||||
log.Info().Msg("Received SIGHUP, reopening log file")
|
||||
if err := reopenLogFile(); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to reopen log file")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,170 +1,429 @@
|
||||
// cmd/gateway/main.go 负责网关进程启动、监听器选择、Minecraft 握手路由、插件钩子分发以及上游转发交接。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/tursom/mc-gateway/internal/pluginmanager"
|
||||
"github.com/tursom/mc-gateway/internal/upstreamtarget"
|
||||
"github.com/tursom/mc-gateway/plugin/api"
|
||||
"github.com/tursom/mc-gateway/protocol"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port int `json:"port"`
|
||||
Hosts map[string]string `json:"hosts"`
|
||||
Default string `json:"default"`
|
||||
}
|
||||
|
||||
var config Config
|
||||
|
||||
func loadConfig() error {
|
||||
file, err := os.Open("config.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
byteValue, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return json.Unmarshal(byteValue, &config)
|
||||
}
|
||||
|
||||
func watchConfig() *fsnotify.Watcher {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to create config watcher")
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
_, ok := <-watcher.Events
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
loadConfig()
|
||||
}
|
||||
}()
|
||||
err = watcher.Add("config.json")
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to watch config file")
|
||||
}
|
||||
|
||||
return watcher
|
||||
}
|
||||
|
||||
func main() {
|
||||
// 插件子命令复用网关二进制。这里先于运行态配置加载处理它们,
|
||||
// 这样本地构建和清单命令不需要一份可用的网关部署配置。
|
||||
if handled, code := runPluginCLI(os.Args[1:]); handled {
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
if err := loadConfig(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
watcher := watchConfig()
|
||||
defer watcher.Close()
|
||||
|
||||
// 监听TCP端口
|
||||
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", config.Port))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
if err := writePIDFile(); err != nil {
|
||||
log.Err(err).Msg("Failed to write PID file")
|
||||
}
|
||||
defer listener.Close()
|
||||
log.Info().
|
||||
Int("port", config.Port).
|
||||
Msg("Listening")
|
||||
defer removePIDFile()
|
||||
defer closeGatewayRuntime()
|
||||
|
||||
for {
|
||||
// 接受传入的连接
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Error accepting")
|
||||
continue
|
||||
go handleLogRotate()
|
||||
|
||||
defer exitWaitGroup.Wait()
|
||||
|
||||
startEnabledServices()
|
||||
}
|
||||
|
||||
func startEnabledServices() {
|
||||
// TCP 和 Admin HTTP 始终通过共享监听器启动。共享监听器按每条连接
|
||||
// 的首包判断它是 HTTP 还是 Minecraft 协议数据,因此不需要额外维护
|
||||
// 一个手动模式开关。
|
||||
startService(runTcpWebPortReuse)
|
||||
|
||||
// 可选传输最终仍进入 handleRequest,这让插件过滤、路由解析和上游拨号
|
||||
// 在 TCP、KCP、QUIC 和 WebSocket 入口之间保持一致。
|
||||
if config.Kcp.Enable {
|
||||
startService(runKcp)
|
||||
}
|
||||
// 处理连接
|
||||
go handleRequest(conn)
|
||||
if config.Quic.Enable {
|
||||
startService(runQuic)
|
||||
}
|
||||
if config.WebSocket.Enable && normalizedWebSocketPort() != normalizedTCPPort() {
|
||||
startService(runWebSocket)
|
||||
}
|
||||
}
|
||||
|
||||
func startService(run func(wg *sync.WaitGroup)) {
|
||||
exitWaitGroup.Add(1)
|
||||
go run(&exitWaitGroup)
|
||||
}
|
||||
|
||||
func handleRequest(conn net.Conn) {
|
||||
gatewayMetrics.ConnectionStarted()
|
||||
defer gatewayMetrics.ConnectionFinished()
|
||||
|
||||
// 插件或协议解析器的 panic 不能杀掉监听协程;当前连接会被放弃,
|
||||
// 进程继续服务其他客户端。
|
||||
defer func() {
|
||||
rec := recover()
|
||||
if rec == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err, ok := rec.(error); ok {
|
||||
log.Err(err).
|
||||
Str("client", conn.RemoteAddr().String()).
|
||||
Msg("Panic on handle request")
|
||||
} else {
|
||||
log.Error().Any("err", rec).
|
||||
Str("client", conn.RemoteAddr().String()).
|
||||
Msg("Panic on handle request")
|
||||
}
|
||||
}()
|
||||
|
||||
// 确保连接关闭
|
||||
defer conn.Close()
|
||||
|
||||
buf := make([]byte, 1024)
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Error reading hostname")
|
||||
return
|
||||
}
|
||||
mc_host := GetMcHost(buf[:n])
|
||||
host, ok := config.Hosts[mc_host]
|
||||
if !ok {
|
||||
host = config.Default
|
||||
}
|
||||
|
||||
client, err := net.Dial("tcp", host)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Error dialing")
|
||||
client := mapToHost(conn)
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
client.Write(buf[:n])
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
go handleRead(client, conn, &wg)
|
||||
go handleWrite(client, conn, &wg)
|
||||
|
||||
wg.Wait()
|
||||
proxyConnections(conn, client)
|
||||
}
|
||||
|
||||
func handleRead(srv, cli net.Conn, wg *sync.WaitGroup) {
|
||||
defer func() {
|
||||
wg.Done()
|
||||
srv.Close()
|
||||
cli.Close()
|
||||
}()
|
||||
|
||||
buf := make([]byte, 1024)
|
||||
|
||||
for {
|
||||
n, err := srv.Read(buf)
|
||||
func mapToHost(conn net.Conn) net.Conn {
|
||||
// 连接过滤器在读取 Minecraft 握手前执行,因此可以按来源地址或传输类型
|
||||
// 拒绝连接,同时不消耗客户端发送的协议字节。
|
||||
if pluginsManager != nil {
|
||||
transport, _, _ := connectionIngress(conn)
|
||||
filter, err := pluginsManager.FilterConnection(context.Background(), api.ConnectionFilterRequest{
|
||||
SourceAddr: conn.RemoteAddr().String(),
|
||||
Transport: transport,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
log.Err(err).Str("client", conn.RemoteAddr().String()).Msg("connection filter failed")
|
||||
return nil
|
||||
}
|
||||
if !filter.Allowed {
|
||||
log.Info().Str("client", conn.RemoteAddr().String()).Str("plugin", filter.PluginID).Str("reason", filter.Reason).Msg("connection rejected by filter")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
cli.Write(buf[:n])
|
||||
}
|
||||
}
|
||||
buf := getProxyBuffer()
|
||||
defer putProxyBuffer(buf)
|
||||
|
||||
func handleWrite(srv, cli net.Conn, wg *sync.WaitGroup) {
|
||||
defer func() {
|
||||
wg.Done()
|
||||
srv.Close()
|
||||
cli.Close()
|
||||
}()
|
||||
|
||||
buf := make([]byte, 1024)
|
||||
|
||||
for {
|
||||
n, err := cli.Read(buf)
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
return
|
||||
log.Err(err).
|
||||
Str("client", conn.RemoteAddr().String()).
|
||||
Msg("failed to reading hostname")
|
||||
return nil
|
||||
}
|
||||
if n == 0 {
|
||||
log.Err(errEmptyBuffer).
|
||||
Str("client", conn.RemoteAddr().String()).
|
||||
Msg("buffer is empty")
|
||||
return nil
|
||||
}
|
||||
|
||||
srv.Write(buf[:n])
|
||||
// 第一次读取包含 Minecraft 握手数据。所有过滤器和路由决策完成后,
|
||||
// 这段数据必须原样或按插件改写后回放给选中的上游。
|
||||
initialData := append([]byte(nil), buf[:n]...)
|
||||
handshake := protocol.ParseHandshake(initialData)
|
||||
if handshake.ServerHost == "" {
|
||||
log.Err(errEmptyBuffer).
|
||||
Str("client", conn.RemoteAddr().String()).
|
||||
Msg("failed to parse mc host from buffer")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 握手过滤器可以改写目标主机名。发生改写时要立刻重建首包,
|
||||
// 确保上游看到的是改写后的 Minecraft 主机名,而不是客户端原始值。
|
||||
if pluginsManager != nil {
|
||||
filter, err := pluginsManager.FilterHandshake(context.Background(), api.HandshakeFilterRequest{
|
||||
SourceAddr: conn.RemoteAddr().String(),
|
||||
ServerHost: handshake.ServerHost,
|
||||
RawServerHost: handshake.RawServerHost,
|
||||
ProtocolVersion: handshake.ProtocolVersion,
|
||||
NextState: handshake.NextState,
|
||||
})
|
||||
if err != nil {
|
||||
log.Err(err).Str("client", conn.RemoteAddr().String()).Str("host", handshake.ServerHost).Msg("handshake filter failed")
|
||||
return nil
|
||||
}
|
||||
if !filter.Allowed {
|
||||
log.Info().Str("client", conn.RemoteAddr().String()).Str("host", handshake.ServerHost).Str("plugin", filter.PluginID).Str("reason", filter.Reason).Msg("handshake rejected by filter")
|
||||
return nil
|
||||
}
|
||||
if filter.RewriteHost != "" && filter.RewriteHost != handshake.ServerHost {
|
||||
initialData = protocol.ReplaceMcHost(initialData, filter.RewriteHost)
|
||||
handshake = protocol.ParseHandshake(initialData)
|
||||
}
|
||||
}
|
||||
|
||||
// 状态查询使用 NextState=1,并且可以由插件直接完整响应。
|
||||
// 如果这里已经处理,就不会再为该查询打开上游连接。
|
||||
if handshake.NextState == 1 {
|
||||
if handled := handleStatusPing(conn, handshake); handled {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
routeResult := resolveGatewayRoute(conn, handshake)
|
||||
host := routeResult.Decision.Upstream
|
||||
ok := routeResult.Source != "fallback_miss"
|
||||
if routeResult.Decision.Action == api.RouteDecisionReject || host == "" {
|
||||
gatewayMetrics.RouteMiss()
|
||||
log.Err(errEmptyBuffer).
|
||||
Str("client", conn.RemoteAddr().String()).
|
||||
Str("host", handshake.ServerHost).
|
||||
Str("route_source", routeResult.Source).
|
||||
Str("route_action", routeResult.Decision.Action).
|
||||
Msg("failed to route host")
|
||||
return nil
|
||||
}
|
||||
if ok {
|
||||
gatewayMetrics.RouteHit(handshake.ServerHost)
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("client", conn.RemoteAddr().String()).
|
||||
Str("host", handshake.ServerHost).
|
||||
Str("mc", host).
|
||||
Msg("map to host")
|
||||
|
||||
var client net.Conn
|
||||
|
||||
if pluginsManager != nil {
|
||||
req := newUpstreamConnectRequest(conn, host, handshake, initialData, ok)
|
||||
result, err := pluginsManager.ConnectUpstream(context.Background(), req)
|
||||
if err != nil {
|
||||
if errors.Is(err, api.ErrBlocked) {
|
||||
log.Info().
|
||||
Str("client", conn.RemoteAddr().String()).
|
||||
Str("host", handshake.ServerHost).
|
||||
Msg("managed upstream plugin blocked connection")
|
||||
return nil
|
||||
}
|
||||
log.Err(err).
|
||||
Str("client", conn.RemoteAddr().String()).
|
||||
Str("host", handshake.ServerHost).
|
||||
Str("mc", host).
|
||||
Msg("failed to invoke managed upstream plugin")
|
||||
return nil
|
||||
}
|
||||
if result.Handled {
|
||||
if result.Proxied {
|
||||
return nil
|
||||
}
|
||||
client = result.Conn
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
// 路由值可以通过前缀选择非 TCP 传输;普通地址仍按 TCP 处理,
|
||||
// 以保持旧配置的行为不变。
|
||||
switch target.Protocol {
|
||||
case upstreamtarget.ProtocolQUIC:
|
||||
client = upstreamQuic(target.Address)
|
||||
case upstreamtarget.ProtocolKCP:
|
||||
client = upstreamKcp(target.Address)
|
||||
case upstreamtarget.ProtocolHAProxy:
|
||||
client = haProxyUpstream(conn, target.Address)
|
||||
default:
|
||||
client = upstreamTcp(target.Address)
|
||||
}
|
||||
}
|
||||
if client == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 只有在上游路径确定后才回放握手数据。这样插件在任何上游字节发出前,
|
||||
// 都还有机会阻断、代理或改写连接。
|
||||
if err := writeAll(client, initialData); err != nil {
|
||||
log.Err(err).
|
||||
Str("client", conn.RemoteAddr().String()).
|
||||
Str("host", handshake.ServerHost).
|
||||
Str("mc", host).
|
||||
Msg("failed to write initial packet to upstream")
|
||||
client.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
func resolveGatewayRoute(conn net.Conn, handshake protocol.Handshake) pluginmanager.RouteResolveResult {
|
||||
upstream, hit := lookupRoute(handshake.ServerHost)
|
||||
// SQLite 快照始终作为本地兜底。插件会同时拿到兜底决策和刷新回调,
|
||||
// 因此可以选择性覆盖路由,而不必在插件里复制一套路由仓库逻辑。
|
||||
req := api.RouteResolveRequest{
|
||||
Host: handshake.ServerHost,
|
||||
RawServerHost: handshake.RawServerHost,
|
||||
SourceAddr: conn.RemoteAddr().String(),
|
||||
ProtocolVersion: handshake.ProtocolVersion,
|
||||
NextState: handshake.NextState,
|
||||
FallbackUpstream: upstream,
|
||||
FallbackHit: hit,
|
||||
Handshake: api.UpstreamHandshakeRef{
|
||||
ServerHost: handshake.ServerHost,
|
||||
RawServerHost: handshake.RawServerHost,
|
||||
ProtocolVersion: handshake.ProtocolVersion,
|
||||
NextState: handshake.NextState,
|
||||
},
|
||||
}
|
||||
if pluginsManager != nil {
|
||||
result, err := pluginsManager.ResolveRoute(context.Background(), req, func(req api.RouteResolveRequest) (string, bool) {
|
||||
return lookupRoute(req.Host)
|
||||
})
|
||||
if err == nil {
|
||||
return result
|
||||
}
|
||||
log.Err(err).Str("client", conn.RemoteAddr().String()).Str("host", handshake.ServerHost).Msg("route resolver failed")
|
||||
}
|
||||
action := api.RouteDecisionFallback
|
||||
source := "sqlite_fallback"
|
||||
if upstream == "" {
|
||||
// 没有命中兜底路由时统一表示为拒绝决策,便于热路径记录一致的失败形态。
|
||||
action = api.RouteDecisionReject
|
||||
source = "fallback_miss"
|
||||
}
|
||||
return pluginmanager.RouteResolveResult{
|
||||
Decision: api.RouteDecision{Action: action, Upstream: upstream, ProviderID: "sqlite", Reason: "sqlite route snapshot fallback"},
|
||||
Source: source,
|
||||
}
|
||||
}
|
||||
|
||||
func GetMcHost(buf []byte) string {
|
||||
buf = buf[4:]
|
||||
host_len := buf[0]
|
||||
if len(buf)+1 < int(host_len) {
|
||||
return ""
|
||||
func handleStatusPing(conn net.Conn, handshake protocol.Handshake) bool {
|
||||
if pluginsManager == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return string(buf[1 : host_len+1])
|
||||
// Minecraft 状态响应是带长度前缀的 JSON 数据包。插件只提供高层字段,
|
||||
// Minecraft 协议封包由 protocol.StatusResponsePacket 统一完成。
|
||||
result, err := pluginsManager.StatusPing(context.Background(), api.StatusPingRequest{
|
||||
Host: handshake.ServerHost,
|
||||
RawServerHost: handshake.RawServerHost,
|
||||
SourceAddr: conn.RemoteAddr().String(),
|
||||
ProtocolVersion: handshake.ProtocolVersion,
|
||||
})
|
||||
if err != nil || !result.Handled {
|
||||
if err != nil {
|
||||
log.Err(err).Str("host", handshake.ServerHost).Msg("status ping plugin failed")
|
||||
}
|
||||
return false
|
||||
}
|
||||
payload := map[string]any{
|
||||
"description": map[string]any{"text": result.Response.MOTD},
|
||||
"players": map[string]any{
|
||||
"online": result.Response.OnlinePlayers,
|
||||
"max": result.Response.MaxPlayers,
|
||||
},
|
||||
"version": map[string]any{
|
||||
"name": result.Response.VersionText,
|
||||
"protocol": result.Response.ProtocolVersion,
|
||||
},
|
||||
}
|
||||
if result.Response.Favicon != "" {
|
||||
payload["favicon"] = result.Response.Favicon
|
||||
}
|
||||
if result.Response.Maintenance {
|
||||
payload["maintenance"] = map[string]any{"window": result.Response.MaintenanceWindow}
|
||||
}
|
||||
packet, err := protocol.StatusResponsePacket(payload)
|
||||
if err != nil {
|
||||
log.Err(err).Str("host", handshake.ServerHost).Msg("failed to build status response")
|
||||
return true
|
||||
}
|
||||
if err := writeAll(conn, packet); err != nil {
|
||||
log.Err(err).Str("host", handshake.ServerHost).Msg("failed to write status response")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func newUpstreamConnectRequest(conn net.Conn, upstream string, handshake protocol.Handshake, initialData []byte, routeHit bool) api.UpstreamConnectRequest {
|
||||
target := upstreamtarget.Parse(upstream)
|
||||
transport, serviceName, listenerPort := connectionIngress(conn)
|
||||
// InitialData 使用副本,避免上游插件在其他处理器或日志路径仍引用回放缓冲区时
|
||||
// 意外修改调用方持有的数据。
|
||||
req := api.UpstreamConnectRequest{
|
||||
Source: conn,
|
||||
Host: handshake.ServerHost,
|
||||
Upstream: upstream,
|
||||
InitialData: append([]byte(nil), initialData...),
|
||||
Metadata: map[string]string{"route_hit": boolString(routeHit)},
|
||||
ConnectionID: randomHexID(8),
|
||||
TraceID: randomHexID(16),
|
||||
SourceAddr: conn.RemoteAddr().String(),
|
||||
ServerHost: handshake.ServerHost,
|
||||
RawServerHost: handshake.RawServerHost,
|
||||
ProtocolVersion: handshake.ProtocolVersion,
|
||||
NextState: handshake.NextState,
|
||||
RouteID: handshake.ServerHost,
|
||||
RouteTags: []string{},
|
||||
UpstreamRaw: upstream,
|
||||
UpstreamProtocol: string(target.Protocol),
|
||||
UpstreamAddress: target.Address,
|
||||
Transport: transport,
|
||||
ServiceName: serviceName,
|
||||
ListenerPort: listenerPort,
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func connectionIngress(conn net.Conn) (transport string, serviceName string, listenerPort int) {
|
||||
transport = "tcp"
|
||||
serviceName = serviceNameTCPAdmin
|
||||
// 具体连接包装类型记录了客户端来自哪个监听器。该元数据会传给插件,
|
||||
// 并出现在运维诊断中,同时不需要改变 net.Conn 接口。
|
||||
switch conn.(type) {
|
||||
case *webSocketConn:
|
||||
transport = "websocket"
|
||||
serviceName = serviceNameWebSocket
|
||||
case quicConn:
|
||||
transport = "quic"
|
||||
serviceName = serviceNameQUIC
|
||||
}
|
||||
if addr, ok := conn.LocalAddr().(*net.TCPAddr); ok {
|
||||
listenerPort = addr.Port
|
||||
}
|
||||
return transport, serviceName, listenerPort
|
||||
}
|
||||
|
||||
func boolString(value bool) string {
|
||||
if value {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
|
||||
func randomHexID(size int) string {
|
||||
buf := make([]byte, size)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return hex.EncodeToString([]byte("fallback"))
|
||||
}
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
414
cmd/gateway/main_test.go
Normal file
414
cmd/gateway/main_test.go
Normal file
@@ -0,0 +1,414 @@
|
||||
// cmd/gateway/main_test.go 包含用于约束 gateway 行为的测试。
|
||||
|
||||
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)()
|
||||
|
||||
packet := gatewayTestPacket("play.example", 0x63, 0x00)
|
||||
source := newGatewayTestConn(packet)
|
||||
upstream := newGatewayTestConn(nil)
|
||||
setGatewayTestRoutes(map[string]string{
|
||||
"play.example": "backend.example:25565",
|
||||
})
|
||||
|
||||
var gotSource net.Conn
|
||||
var gotHost string
|
||||
registerGatewayUpstreamHook(
|
||||
t,
|
||||
func(source net.Conn, host string) bool {
|
||||
gotSource = source
|
||||
gotHost = host
|
||||
return host == "backend.example:25565"
|
||||
},
|
||||
func(source net.Conn, host string) (net.Conn, error) {
|
||||
return upstream, nil
|
||||
},
|
||||
)
|
||||
|
||||
got := mapToHost(source)
|
||||
if got != upstream {
|
||||
t.Fatalf("mapToHost() = %v, want upstream conn", got)
|
||||
}
|
||||
if gotSource != source {
|
||||
t.Fatalf("hook source = %v, want original source", gotSource)
|
||||
}
|
||||
if gotHost != "backend.example:25565" {
|
||||
t.Fatalf("hook host = %q, want backend.example:25565", gotHost)
|
||||
}
|
||||
if !bytes.Equal(upstream.writeBuf.Bytes(), packet) {
|
||||
t.Fatalf("upstream initial packet = %v, want %v", upstream.writeBuf.Bytes(), packet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapToHostUsesDefaultRoute(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
packet := gatewayTestPacket("unknown.example")
|
||||
source := newGatewayTestConn(packet)
|
||||
upstream := newGatewayTestConn(nil)
|
||||
setGatewayTestRoutes(map[string]string{
|
||||
"default": "fallback.example:25565",
|
||||
})
|
||||
|
||||
registerGatewayUpstreamHook(
|
||||
t,
|
||||
func(_ net.Conn, host string) bool {
|
||||
return host == "fallback.example:25565"
|
||||
},
|
||||
func(net.Conn, string) (net.Conn, error) {
|
||||
return upstream, nil
|
||||
},
|
||||
)
|
||||
|
||||
if got := mapToHost(source); got != upstream {
|
||||
t.Fatalf("mapToHost() = %v, want fallback upstream", got)
|
||||
}
|
||||
if !bytes.Equal(upstream.writeBuf.Bytes(), packet) {
|
||||
t.Fatalf("upstream initial packet = %v, want %v", upstream.writeBuf.Bytes(), packet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapToHostRejectsInvalidOrUnroutedPackets(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
packet []byte
|
||||
hosts map[string]string
|
||||
}{
|
||||
{
|
||||
name: "read error",
|
||||
packet: nil,
|
||||
hosts: map[string]string{"default": "fallback.example:25565"},
|
||||
},
|
||||
{
|
||||
name: "malformed packet",
|
||||
packet: []byte{0x01, 0x02, 0x03, 0x04, 0x08, 'a'},
|
||||
hosts: map[string]string{"default": "fallback.example:25565"},
|
||||
},
|
||||
{
|
||||
name: "missing route",
|
||||
packet: gatewayTestPacket("unknown.example"),
|
||||
hosts: map[string]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
source := newGatewayTestConn(tt.packet)
|
||||
if tt.packet == nil {
|
||||
source.readErr = errors.New("read failed")
|
||||
}
|
||||
setGatewayTestRoutes(tt.hosts)
|
||||
|
||||
if got := mapToHost(source); got != nil {
|
||||
t.Fatalf("mapToHost() = %v, want nil", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapToHostReturnsNilWhenHookFails(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
source := newGatewayTestConn(gatewayTestPacket("play.example"))
|
||||
setGatewayTestRoutes(map[string]string{
|
||||
"play.example": "backend.example:25565",
|
||||
})
|
||||
wantErr := errors.New("hook failed")
|
||||
|
||||
registerGatewayUpstreamHook(
|
||||
t,
|
||||
func(net.Conn, string) bool { return true },
|
||||
func(net.Conn, string) (net.Conn, error) {
|
||||
return nil, wantErr
|
||||
},
|
||||
)
|
||||
|
||||
if got := mapToHost(source); got != nil {
|
||||
t.Fatalf("mapToHost() = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapToHostClosesUpstreamWhenInitialWriteFails(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
source := newGatewayTestConn(gatewayTestPacket("play.example"))
|
||||
upstream := newGatewayTestConn(nil)
|
||||
upstream.writeErr = errors.New("write failed")
|
||||
setGatewayTestRoutes(map[string]string{
|
||||
"play.example": "backend.example:25565",
|
||||
})
|
||||
|
||||
registerGatewayUpstreamHook(
|
||||
t,
|
||||
func(net.Conn, string) bool { return true },
|
||||
func(net.Conn, string) (net.Conn, error) {
|
||||
return upstream, nil
|
||||
},
|
||||
)
|
||||
|
||||
if got := mapToHost(source); got != nil {
|
||||
t.Fatalf("mapToHost() = %v, want nil", got)
|
||||
}
|
||||
if !upstream.closed {
|
||||
t.Fatal("upstream was not closed after write failure")
|
||||
}
|
||||
}
|
||||
|
||||
type gatewayTestPluginAdapter struct {
|
||||
handler api.UpstreamConnectHandler
|
||||
initHook func(*pluginmanager.Gateway) error
|
||||
}
|
||||
|
||||
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 a.initHook == nil {
|
||||
if err := api.RegisterHookHandler(
|
||||
gateway,
|
||||
api.HookUpstreamConnect,
|
||||
func(api.UpstreamConnectRequest) bool { return true },
|
||||
handler,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if err := a.initHook(gateway); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &gatewayPluginStub{}, nil
|
||||
}
|
||||
|
||||
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()
|
||||
return uploadGatewayTestArtifactWithCapabilities(t, manager, pluginID, "")
|
||||
}
|
||||
|
||||
func uploadGatewayTestArtifactWithCapabilities(t *testing.T, manager *pluginmanager.Manager, pluginID string, capabilities string) pluginmanager.ArtifactRecord {
|
||||
t.Helper()
|
||||
artifact, err := manager.UploadArtifact(context.Background(), pluginmanager.ArtifactUpload{
|
||||
SourcePath: writeGatewayTestMCGPWithCapabilities(t, pluginID, capabilities),
|
||||
FileName: pluginID + ".mcgp",
|
||||
Actor: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UploadArtifact() error = %v", err)
|
||||
}
|
||||
return artifact
|
||||
}
|
||||
|
||||
func uploadGatewayTestArtifactWithManifest(t *testing.T, manager *pluginmanager.Manager, pluginID string, mutate func(*pluginmanager.Manifest)) pluginmanager.ArtifactRecord {
|
||||
t.Helper()
|
||||
var manifest pluginmanager.Manifest
|
||||
if err := json.Unmarshal(gatewayTestManifest(t, pluginID), &manifest); err != nil {
|
||||
t.Fatalf("Unmarshal manifest error = %v", err)
|
||||
}
|
||||
if mutate != nil {
|
||||
mutate(&manifest)
|
||||
}
|
||||
data, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal manifest error = %v", err)
|
||||
}
|
||||
artifact, err := manager.UploadArtifact(context.Background(), pluginmanager.ArtifactUpload{
|
||||
SourcePath: writeGatewayTestMCGPEntries(t, map[string][]byte{
|
||||
"manifest.json": data,
|
||||
"plugin.so": []byte("fake plugin bytes " + pluginID),
|
||||
}),
|
||||
FileName: pluginID + ".mcgp",
|
||||
Actor: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UploadArtifact() error = %v", err)
|
||||
}
|
||||
return artifact
|
||||
}
|
||||
|
||||
func approveGatewayPluginGovernanceForTest(t *testing.T, pluginID, artifactID string) {
|
||||
t.Helper()
|
||||
if _, err := pluginsManager.CreateReview(context.Background(), "admin", pluginID, pluginmanager.GovernanceReviewRequest{
|
||||
ArtifactID: artifactID,
|
||||
Profile: pluginmanager.PolicyProfileProd,
|
||||
Decision: pluginmanager.ReviewDecisionApproved,
|
||||
Notes: "test approval",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateReview(%s) error = %v", pluginID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeGatewayTestMCGP(t *testing.T, pluginID string) string {
|
||||
return writeGatewayTestMCGPWithCapabilities(t, pluginID, "")
|
||||
}
|
||||
|
||||
func writeGatewayTestMCGPWithCapabilities(t *testing.T, pluginID string, capabilities string) string {
|
||||
t.Helper()
|
||||
entries := map[string][]byte{
|
||||
"manifest.json": gatewayTestManifestWithCapabilities(t, pluginID, capabilities),
|
||||
"plugin.so": []byte("fake plugin bytes " + pluginID),
|
||||
}
|
||||
return writeGatewayTestMCGPEntries(t, entries)
|
||||
}
|
||||
|
||||
func writeGatewayTestMCGPEntries(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 zip error = %v", err)
|
||||
}
|
||||
writer := zip.NewWriter(file)
|
||||
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 {
|
||||
return gatewayTestManifestWithCapabilities(t, pluginID, "")
|
||||
}
|
||||
|
||||
func gatewayProtocolProxyCapabilities() string {
|
||||
return `{
|
||||
"upstream_connect":{"mode":"protocol-proxy"},
|
||||
"scope":{"type":"host","values":["play.example"]},
|
||||
"rollout":{"mode":"canary"},
|
||||
"minecraft":{
|
||||
"protocol_versions":{"tested":[767]},
|
||||
"forwarding":{"supported":["none"],"default":"none"}
|
||||
}
|
||||
}`
|
||||
}
|
||||
|
||||
func gatewayTestManifestWithCapabilities(t *testing.T, pluginID string, capabilities string) []byte {
|
||||
t.Helper()
|
||||
if capabilities == "" {
|
||||
capabilities = `{"extension_points":["upstream.connect/v1"]}`
|
||||
}
|
||||
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,
|
||||
}},
|
||||
Capabilities: json.RawMessage(capabilities),
|
||||
}
|
||||
data, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal manifest error = %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
33
cmd/gateway/pid.go
Normal file
33
cmd/gateway/pid.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// cmd/gateway/pid.go 维护 pid 文件的写入和清理,供进程管理器按文件追踪网关进程。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
var currentPidFile string
|
||||
|
||||
func writePIDFile() error {
|
||||
newPidFile := getPidFileFromConfig()
|
||||
if newPidFile == "" || newPidFile == currentPidFile {
|
||||
return nil
|
||||
}
|
||||
|
||||
if currentPidFile != "" {
|
||||
removePIDFile()
|
||||
}
|
||||
currentPidFile = newPidFile
|
||||
|
||||
pid := os.Getpid()
|
||||
if err := os.WriteFile(newPidFile, []byte(fmt.Sprintf("%d\n", pid)), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func removePIDFile() {
|
||||
os.Remove(currentPidFile)
|
||||
}
|
||||
14
cmd/gateway/pid_unix.go
Normal file
14
cmd/gateway/pid_unix.go
Normal file
@@ -0,0 +1,14 @@
|
||||
// cmd/gateway/pid_unix.go 实现 Unix 平台的 pid 文件占用检查,避免覆盖仍在运行的进程记录。
|
||||
|
||||
// pid_unix.go
|
||||
//go:build unix || plan9
|
||||
|
||||
package main
|
||||
|
||||
func getPidFileFromConfig() string {
|
||||
pidFile := config.PidFile
|
||||
if pidFile == "" {
|
||||
return "/dev/shm/mc-gateway.pid"
|
||||
}
|
||||
return pidFile
|
||||
}
|
||||
15
cmd/gateway/pid_unix_test.go
Normal file
15
cmd/gateway/pid_unix_test.go
Normal file
@@ -0,0 +1,15 @@
|
||||
//go:build unix || plan9
|
||||
|
||||
// cmd/gateway/pid_unix_test.go 包含用于约束 pid unix 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestGetPidFileFromConfigDefaultOnUnix(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
if got := getPidFileFromConfig(); got != "/dev/shm/mc-gateway.pid" {
|
||||
t.Fatalf("getPidFileFromConfig() = %q, want /dev/shm/mc-gateway.pid", got)
|
||||
}
|
||||
}
|
||||
10
cmd/gateway/pid_windows.go
Normal file
10
cmd/gateway/pid_windows.go
Normal file
@@ -0,0 +1,10 @@
|
||||
// cmd/gateway/pid_windows.go 在 Windows 上提供可移植的 pid 文件占用检查替代实现。
|
||||
|
||||
// pid_windows.go
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
func getPidFileFromConfig() string {
|
||||
return config.PidFile
|
||||
}
|
||||
255
cmd/gateway/plugin.go
Normal file
255
cmd/gateway/plugin.go
Normal file
@@ -0,0 +1,255 @@
|
||||
// cmd/gateway/plugin.go 把网关运行时接入 pluginmanager,负责钩子分发和插件生命周期加载。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"plugin"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/tursom/mc-gateway/plugin/api"
|
||||
)
|
||||
|
||||
var (
|
||||
exitWaitGroup sync.WaitGroup
|
||||
|
||||
pluginLock sync.RWMutex
|
||||
plugins = make(map[string]api.Plugin)
|
||||
|
||||
hooks = make(map[string]map[string]any)
|
||||
)
|
||||
|
||||
type (
|
||||
Gateway struct {
|
||||
pluginId string
|
||||
}
|
||||
)
|
||||
|
||||
func loadPlugins() {
|
||||
pluginLock.Lock()
|
||||
defer pluginLock.Unlock()
|
||||
|
||||
for pluginKey, pluginConfig := range config.Plugin {
|
||||
if enable, ok := pluginConfig["enable"].(bool); ok && enable {
|
||||
pluginFile := pluginKey
|
||||
if file, ok := pluginConfig["file"].(string); ok {
|
||||
pluginFile = file
|
||||
}
|
||||
|
||||
if _, ok := plugins[pluginKey]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
gateway := &Gateway{
|
||||
pluginId: pluginKey,
|
||||
}
|
||||
|
||||
log.Info().Str("plugin", pluginKey).Msg("Loading plugin")
|
||||
p, err := plugin.Open(pluginFile + ".so")
|
||||
if err != nil {
|
||||
log.Err(err).Str("plugin", pluginKey).Msg("Failed to open plugin")
|
||||
continue
|
||||
}
|
||||
|
||||
pluginSymbol, err := p.Lookup("Plugin")
|
||||
if err != nil {
|
||||
log.Err(err).Str("plugin", pluginKey).Msg("Failed to lookup plugin")
|
||||
continue
|
||||
}
|
||||
pluginFactory, ok := pluginSymbol.(func() api.Plugin)
|
||||
if !ok {
|
||||
log.Err(err).Str("plugin", pluginKey).Msg("Invalid plugin factory signature")
|
||||
continue
|
||||
}
|
||||
pluginInstance := pluginFactory()
|
||||
cfgObj := pluginInstance.NewConfigObj()
|
||||
if err := loadPluginConfig(pluginConfig, cfgObj); err != nil {
|
||||
log.Err(err).Str("plugin", pluginKey).Msg("Failed to load plugin config")
|
||||
continue
|
||||
}
|
||||
|
||||
if err := pluginInstance.ReloadConfig(cfgObj); err != nil {
|
||||
log.Err(err).Str("plugin", pluginKey).Msg("Failed to reload plugin config")
|
||||
continue
|
||||
}
|
||||
|
||||
if err := pluginInstance.Init(gateway); err != nil {
|
||||
log.Err(err).Str("plugin", pluginKey).Msg("Failed to initialize plugin")
|
||||
continue
|
||||
}
|
||||
|
||||
plugins[pluginKey] = pluginInstance
|
||||
hooks[pluginKey] = make(map[string]any)
|
||||
log.Info().Str("plugin", pluginKey).Msg("Plugin loaded successfully")
|
||||
} else {
|
||||
if plugin, ok := plugins[pluginKey]; ok {
|
||||
if err := plugin.Destroy(); err != nil {
|
||||
log.Err(err).Str("plugin", pluginKey).Msg("Failed to destroy plugin")
|
||||
}
|
||||
log.Info().Str("plugin", pluginKey).Msg("Plugin disabled")
|
||||
}
|
||||
delete(plugins, pluginKey)
|
||||
delete(hooks, pluginKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HandleConn 实现 api.Gateway,用于让插件把连接交回网关主流程。
|
||||
func (g *Gateway) HandleConn(conn net.Conn) {
|
||||
go handleRequest(conn)
|
||||
}
|
||||
|
||||
// Hook 实现 api.Gateway,用于注册旧版内存钩子处理器。
|
||||
func (g *Gateway) Hook(hook string, handler any) error {
|
||||
pluginLock.Lock()
|
||||
defer pluginLock.Unlock()
|
||||
|
||||
hooks[g.pluginId][hook] = handler
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExitWaitGroup 实现 api.Gateway,用于把插件后台任务纳入进程退出等待。
|
||||
func (g *Gateway) ExitWaitGroup() *sync.WaitGroup {
|
||||
return &exitWaitGroup
|
||||
}
|
||||
|
||||
func (g *Gateway) EmitEvent(ctx context.Context, name string, fields map[string]string) error {
|
||||
_ = ctx
|
||||
_ = name
|
||||
_ = fields
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *Gateway) ObserveMetric(ctx context.Context, name string, value float64, labels map[string]string) error {
|
||||
_ = ctx
|
||||
_ = name
|
||||
_ = value
|
||||
_ = labels
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *Gateway) Logger() api.Logger {
|
||||
return noopPluginLogger{}
|
||||
}
|
||||
|
||||
func (g *Gateway) DataStore() api.DataStore {
|
||||
return noopPluginDataStore{}
|
||||
}
|
||||
|
||||
func (g *Gateway) FileStore() api.FileStore {
|
||||
return noopPluginFileStore{}
|
||||
}
|
||||
|
||||
func (g *Gateway) ExternalClient(name string) api.ExternalClient {
|
||||
_ = name
|
||||
return noopExternalClient{}
|
||||
}
|
||||
|
||||
func (g *Gateway) RegisterBackgroundTask(task api.BackgroundTask) error {
|
||||
_ = task
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestOp 实现 api.Gateway,保留给测试或调试插件能力探测。
|
||||
func (g *Gateway) TestOp() {
|
||||
panic("unimplemented")
|
||||
}
|
||||
|
||||
type noopPluginLogger struct{}
|
||||
|
||||
func (noopPluginLogger) Debug(context.Context, string, map[string]string) {}
|
||||
func (noopPluginLogger) Info(context.Context, string, map[string]string) {}
|
||||
func (noopPluginLogger) Warn(context.Context, string, map[string]string) {}
|
||||
func (noopPluginLogger) Error(context.Context, string, map[string]string) {}
|
||||
|
||||
type noopPluginDataStore struct{}
|
||||
|
||||
func (noopPluginDataStore) Put(context.Context, api.DataRecord) error { return nil }
|
||||
func (noopPluginDataStore) Get(context.Context, string) (api.DataRecord, error) {
|
||||
return api.DataRecord{}, errors.New("plugin data store is unavailable")
|
||||
}
|
||||
func (noopPluginDataStore) Delete(context.Context, string) error { return nil }
|
||||
|
||||
type noopPluginFileStore struct{}
|
||||
|
||||
func (noopPluginFileStore) ResourcePath(string) (string, error) {
|
||||
return "", errors.New("plugin file store is unavailable")
|
||||
}
|
||||
func (noopPluginFileStore) Write(context.Context, string, string, []byte, string, time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
func (noopPluginFileStore) Read(context.Context, string, string, int64) ([]byte, error) {
|
||||
return nil, errors.New("plugin file store is unavailable")
|
||||
}
|
||||
func (noopPluginFileStore) Delete(context.Context, string, string) error { return nil }
|
||||
|
||||
type noopExternalClient struct{}
|
||||
|
||||
func (noopExternalClient) DoHTTP(context.Context, api.ExternalRequest) (api.ExternalResponse, error) {
|
||||
return api.ExternalResponse{}, errors.New("external client is unavailable")
|
||||
}
|
||||
func (noopExternalClient) DialTCP(context.Context, string, time.Duration) (net.Conn, error) {
|
||||
return nil, errors.New("external client is unavailable")
|
||||
}
|
||||
func (noopExternalClient) HealthCheck(context.Context) error { return nil }
|
||||
|
||||
func Handler1[T1, R any](t1 T1) func(func(T1) R) R {
|
||||
return func(acceptor func(T1) R) R {
|
||||
return acceptor(t1)
|
||||
}
|
||||
}
|
||||
|
||||
func Handler2[T1, T2, R any](t1 T1, t2 T2) func(func(T1, T2) R) R {
|
||||
return func(acceptor func(T1, T2) R) R {
|
||||
return acceptor(t1, t2)
|
||||
}
|
||||
}
|
||||
|
||||
func Handler1R2[T1, R1, R2 any](t1 T1) func(func(T1) (R1, R2)) (R1, R2) {
|
||||
return func(acceptor func(T1) (R1, R2)) (R1, R2) {
|
||||
return acceptor(t1)
|
||||
}
|
||||
}
|
||||
|
||||
func invokeFirstHookHandler[Acceptor, Handler any](
|
||||
hook api.HookType[Acceptor, Handler],
|
||||
acceptor func(Acceptor) bool,
|
||||
handelr func(Handler) error,
|
||||
) (bool, error) {
|
||||
pluginLock.RLock()
|
||||
defer pluginLock.RUnlock()
|
||||
|
||||
for _, handlers := range hooks {
|
||||
if handler, ok := handlers[hook.Key()].(api.HookHandler[Acceptor, Handler]); ok && acceptor(handler.Acceptor()) {
|
||||
if err := handelr(handler.Handler()); err != nil {
|
||||
return true, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func invokeAllHookHandler[Acceptor, Handler any](
|
||||
hook api.HookType[Acceptor, Handler],
|
||||
acceptor func(Acceptor) bool,
|
||||
handelr func(Handler) error,
|
||||
) error {
|
||||
pluginLock.RLock()
|
||||
defer pluginLock.RUnlock()
|
||||
|
||||
for _, handlers := range hooks {
|
||||
if handler, ok := handlers[hook.Key()].(api.HookHandler[Acceptor, Handler]); ok && acceptor(handler.Acceptor()) {
|
||||
if err := handelr(handler.Handler()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
400
cmd/gateway/plugin_cli.go
Normal file
400
cmd/gateway/plugin_cli.go
Normal file
@@ -0,0 +1,400 @@
|
||||
// cmd/gateway/plugin_cli.go 分发插件相关子命令,包括本地脚手架、构建、清单和远程管理操作。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/tursom/mc-gateway/internal/admindb"
|
||||
"github.com/tursom/mc-gateway/internal/pluginmanager"
|
||||
)
|
||||
|
||||
func runPluginCLI(args []string) (bool, int) {
|
||||
if len(args) < 1 || args[0] != "plugin" {
|
||||
return false, 0
|
||||
}
|
||||
if len(args) < 2 {
|
||||
printPluginCLIUsage()
|
||||
return true, 2
|
||||
}
|
||||
|
||||
command := args[1]
|
||||
switch command {
|
||||
case "init":
|
||||
if err := runPluginInitCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "features":
|
||||
if err := runPluginFeaturesCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "manifest":
|
||||
if err := runPluginManifestCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "preflight":
|
||||
if err := runPluginPreflightCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "self-test":
|
||||
if err := runPluginSelfTestCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "benchmark":
|
||||
if err := runPluginBenchmarkCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "status":
|
||||
if err := runPluginRemoteStatusCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "upload":
|
||||
if err := runPluginRemoteUploadCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "enable":
|
||||
if err := runPluginRemoteDesiredCLI(args[2:], pluginmanager.DesiredEnabled); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "disable":
|
||||
if err := runPluginRemoteActionCLI(args[2:], "disable"); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "delete":
|
||||
if err := runPluginRemoteActionCLI(args[2:], "delete"); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "rollback":
|
||||
if err := runPluginRemoteRollbackCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "config":
|
||||
if err := runPluginRemoteConfigCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "secret":
|
||||
if err := runPluginRemoteSecretCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "logs", "events", "metrics":
|
||||
if err := runPluginRemoteOperationsSectionCLI(command, args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "diagnose":
|
||||
if err := runPluginRemoteDiagnoseCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "task":
|
||||
if err := runPluginRemoteTaskCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "data", "files":
|
||||
if err := runPluginRemoteResourceCLI(command, args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "gc":
|
||||
if err := runPluginRemoteGCCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "review":
|
||||
if err := runPluginRemoteReviewCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "advisory":
|
||||
if err := runPluginRemoteAdvisoryCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "repo":
|
||||
if err := runPluginRemoteRepoCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "sbom", "verify":
|
||||
if err := runPluginRemoteSupplyChainCLI(command, args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "runtime":
|
||||
if err := runPluginRuntimeCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "schema", "contract", "conformance", "export", "import", "diff", "drift", "dr-drill", "sign":
|
||||
if err := runPluginReservedCLI(command, args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "build":
|
||||
if err := runPluginBuildCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "test":
|
||||
if err := runPluginTestCLI(args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
return true, 0
|
||||
case "inspect":
|
||||
if len(args) < 3 {
|
||||
printPluginCLIUsage()
|
||||
return true, 2
|
||||
}
|
||||
packagePath := args[2]
|
||||
manifest, err := readPackageManifest(packagePath)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
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":
|
||||
artifact, err := runPluginValidatePathCLI(args[2:], "")
|
||||
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
|
||||
case "source-validate":
|
||||
source, err := runPluginValidatePathCLI(args[2:], pluginmanager.ArtifactTypeSource)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "ok source plugin=%s version=%s source_sha256=%s api=%s go=%s %s/%s\n",
|
||||
source.PluginID, source.Version, source.SHA256, source.APIVersion, source.GoVersion, source.GOOS, source.GOARCH)
|
||||
return true, 0
|
||||
case "source-build":
|
||||
if len(args) < 3 {
|
||||
printPluginCLIUsage()
|
||||
return true, 2
|
||||
}
|
||||
packagePath := args[2]
|
||||
outPath := ""
|
||||
if len(args) >= 4 {
|
||||
outPath = args[3]
|
||||
}
|
||||
build, out, err := buildSourcePackageForCLI(packagePath, outPath)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return true, 1
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "ok build=%d plugin=%s source_sha256=%s artifact_sha256=%s builder=%s go=%s status=%s out=%s\n",
|
||||
build.ID, build.PluginID, build.SourceSHA256, build.ArtifactSHA256, build.BuilderType, build.GoVersion, build.Status, out)
|
||||
return true, 0
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown plugin command %q\n", command)
|
||||
return true, 2
|
||||
}
|
||||
}
|
||||
|
||||
func printPluginCLIUsage() {
|
||||
fmt.Fprintln(os.Stderr, "usage: gateway plugin init|features|manifest|build|test|preflight|self-test|benchmark|status|upload|enable|disable|delete|rollback|config|secret|logs|events|metrics|diagnose|task|data|files|gc|review|advisory|repo|sbom|verify|runtime|inspect|validate|compat|source-validate|source-build ...")
|
||||
}
|
||||
|
||||
func buildSourcePackageForCLI(packagePath, outPath string) (pluginmanager.BuildRecord, string, error) {
|
||||
tmpRoot, err := os.MkdirTemp("", "mcgp-source-build-cli-*")
|
||||
if err != nil {
|
||||
return pluginmanager.BuildRecord{}, "", err
|
||||
}
|
||||
defer os.RemoveAll(tmpRoot)
|
||||
db, err := openPluginCLIDB(filepath.Join(tmpRoot, "plugins.db"))
|
||||
if err != nil {
|
||||
return pluginmanager.BuildRecord{}, "", err
|
||||
}
|
||||
defer db.Close()
|
||||
manager := pluginmanager.New(pluginmanager.Options{
|
||||
DB: db,
|
||||
ArtifactRoot: filepath.Join(tmpRoot, "artifacts"),
|
||||
})
|
||||
source, err := manager.UploadSource(context.Background(), pluginmanager.ArtifactUpload{
|
||||
SourcePath: packagePath,
|
||||
FileName: filepath.Base(packagePath),
|
||||
Actor: "cli",
|
||||
})
|
||||
if err != nil {
|
||||
return pluginmanager.BuildRecord{}, "", err
|
||||
}
|
||||
builds, err := manager.ListBuilds(context.Background(), source.PluginID)
|
||||
if err != nil {
|
||||
return pluginmanager.BuildRecord{}, "", err
|
||||
}
|
||||
var queued pluginmanager.BuildRecord
|
||||
for _, build := range builds {
|
||||
if build.SourceID == source.ID && build.Status == pluginmanager.BuildStatusQueued {
|
||||
queued = build
|
||||
break
|
||||
}
|
||||
}
|
||||
if queued.ID == 0 {
|
||||
return pluginmanager.BuildRecord{}, "", fmt.Errorf("source upload did not create a queued build")
|
||||
}
|
||||
build, err := manager.RunBuild(context.Background(), "cli", queued.ID)
|
||||
if err != nil {
|
||||
return pluginmanager.BuildRecord{}, "", err
|
||||
}
|
||||
if build.Status != pluginmanager.BuildStatusSucceeded {
|
||||
if build.LogSummary != "" {
|
||||
return build, "", fmt.Errorf("source build status %s: %s\n%s", build.Status, build.Error, build.LogSummary)
|
||||
}
|
||||
return build, "", fmt.Errorf("source build status %s: %s", build.Status, build.Error)
|
||||
}
|
||||
artifact, err := manager.Artifact(context.Background(), build.ArtifactID)
|
||||
if err != nil {
|
||||
return build, "", err
|
||||
}
|
||||
if outPath == "" {
|
||||
outPath = filepath.Join(filepath.Dir(packagePath), artifact.PluginID+"-built.mcgp")
|
||||
}
|
||||
if err := packageBinaryArtifact(artifact, outPath); err != nil {
|
||||
return build, "", err
|
||||
}
|
||||
return build, outPath, nil
|
||||
}
|
||||
|
||||
func packageBinaryArtifact(artifact pluginmanager.ArtifactRecord, outPath string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.Create(outPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
zw := zip.NewWriter(out)
|
||||
for _, entry := range []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{name: "manifest.json", path: filepath.Join(filepath.Dir(artifact.FilePath), "manifest.json")},
|
||||
{name: pluginmanager.RuntimeEntry, path: artifact.FilePath},
|
||||
} {
|
||||
if err := addZipFile(zw, entry.name, entry.path); err != nil {
|
||||
zw.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
func addZipFile(zw *zip.Writer, name, path string) error {
|
||||
writer, err := zw.Create(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
_, err = io.Copy(writer, file)
|
||||
return err
|
||||
}
|
||||
|
||||
func openPluginCLIDB(path string) (*sql.DB, error) {
|
||||
db, err := admindb.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := admindb.Migrate(db); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
348
cmd/gateway/plugin_cli_manifest.go
Normal file
348
cmd/gateway/plugin_cli_manifest.go
Normal file
@@ -0,0 +1,348 @@
|
||||
// cmd/gateway/plugin_cli_manifest.go 实现插件包清单的查看、校验、特性列表和格式化命令。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
"github.com/tursom/mc-gateway/internal/pluginmanager"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
manifestFormatJSON = "json"
|
||||
manifestFormatJSONC = "jsonc"
|
||||
manifestFormatYAML = "yaml"
|
||||
manifestFormatTOML = "toml"
|
||||
)
|
||||
|
||||
var manifestSourceNames = []string{
|
||||
"manifest.yaml",
|
||||
"manifest.yml",
|
||||
"manifest.toml",
|
||||
"manifest.jsonc",
|
||||
"manifest.json",
|
||||
}
|
||||
|
||||
type pluginManifestSource struct {
|
||||
Path string
|
||||
Format string
|
||||
Data []byte
|
||||
Manifest pluginmanager.Manifest
|
||||
Raw map[string]any
|
||||
CanonicalJSON []byte
|
||||
}
|
||||
|
||||
func readPluginDirManifest(dir, explicitManifestPath string) (pluginmanager.Manifest, map[string]any, error) {
|
||||
source, err := readPluginManifestSource(dir, explicitManifestPath)
|
||||
if err != nil {
|
||||
return pluginmanager.Manifest{}, nil, err
|
||||
}
|
||||
return source.Manifest, source.Raw, nil
|
||||
}
|
||||
|
||||
func readPluginManifestSource(target, explicitManifestPath string) (pluginManifestSource, error) {
|
||||
manifestPath, err := resolveManifestSourcePath(target, explicitManifestPath)
|
||||
if err != nil {
|
||||
return pluginManifestSource{}, err
|
||||
}
|
||||
return readPluginManifestSourceFile(manifestPath)
|
||||
}
|
||||
|
||||
func resolveManifestSourcePath(target, explicitManifestPath string) (string, error) {
|
||||
if explicitManifestPath != "" {
|
||||
return resolveExplicitManifestPath(target, explicitManifestPath)
|
||||
}
|
||||
info, err := os.Stat(target)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
if !isManifestSourceFile(target) {
|
||||
return "", fmt.Errorf("manifest path must be one of %s, got %q", strings.Join(manifestSourceNames, ", "), target)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
var found []string
|
||||
for _, name := range manifestSourceNames {
|
||||
candidate := filepath.Join(target, name)
|
||||
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||
found = append(found, candidate)
|
||||
}
|
||||
}
|
||||
if len(found) == 0 {
|
||||
return "", fmt.Errorf("plugin manifest is required: expected one of %s in %s", strings.Join(manifestSourceNames, ", "), target)
|
||||
}
|
||||
if len(found) > 1 {
|
||||
sort.Strings(found)
|
||||
return "", fmt.Errorf("multiple plugin manifests found: %s; pass --manifest to select one", strings.Join(found, ", "))
|
||||
}
|
||||
return found[0], nil
|
||||
}
|
||||
|
||||
func resolveExplicitManifestPath(target, explicitManifestPath string) (string, error) {
|
||||
candidates := []string{explicitManifestPath}
|
||||
if info, err := os.Stat(target); err == nil && info.IsDir() && !filepath.IsAbs(explicitManifestPath) {
|
||||
candidates = []string{filepath.Join(target, explicitManifestPath), explicitManifestPath}
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
info, err := os.Stat(candidate)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.IsDir() {
|
||||
return "", fmt.Errorf("manifest path %q is a directory", candidate)
|
||||
}
|
||||
if !isManifestSourceFile(candidate) {
|
||||
return "", fmt.Errorf("manifest path must be one of %s, got %q", strings.Join(manifestSourceNames, ", "), candidate)
|
||||
}
|
||||
return candidate, nil
|
||||
}
|
||||
return "", fmt.Errorf("manifest path %q is not readable", explicitManifestPath)
|
||||
}
|
||||
|
||||
func readPluginManifestSourceFile(manifestPath string) (pluginManifestSource, error) {
|
||||
format, err := manifestFormatForPath(manifestPath)
|
||||
if err != nil {
|
||||
return pluginManifestSource{}, err
|
||||
}
|
||||
data, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
return pluginManifestSource{}, err
|
||||
}
|
||||
raw, err := decodeManifestSource(data, format)
|
||||
if err != nil {
|
||||
return pluginManifestSource{}, fmt.Errorf("invalid %s: %w", filepath.Base(manifestPath), err)
|
||||
}
|
||||
canonical, err := json.MarshalIndent(raw, "", " ")
|
||||
if err != nil {
|
||||
return pluginManifestSource{}, err
|
||||
}
|
||||
canonical = append(canonical, '\n')
|
||||
var manifest pluginmanager.Manifest
|
||||
if err := json.Unmarshal(canonical, &manifest); err != nil {
|
||||
return pluginManifestSource{}, fmt.Errorf("invalid %s object: %w", filepath.Base(manifestPath), err)
|
||||
}
|
||||
if manifest.ID == "" {
|
||||
return pluginManifestSource{}, errors.New("manifest id is required")
|
||||
}
|
||||
return pluginManifestSource{
|
||||
Path: manifestPath,
|
||||
Format: format,
|
||||
Data: data,
|
||||
Manifest: manifest,
|
||||
Raw: raw,
|
||||
CanonicalJSON: canonical,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func decodeManifestSource(data []byte, format string) (map[string]any, error) {
|
||||
var raw any
|
||||
switch format {
|
||||
case manifestFormatJSON:
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case manifestFormatJSONC:
|
||||
stripped, err := stripJSONC(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(stripped, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case manifestFormatYAML:
|
||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case manifestFormatTOML:
|
||||
var table map[string]any
|
||||
if err := toml.Unmarshal(data, &table); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw = table
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported manifest format %q", format)
|
||||
}
|
||||
normalized, ok := normalizeManifestValue(raw).(map[string]any)
|
||||
if !ok {
|
||||
return nil, errors.New("manifest root must be an object")
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeManifestValue(value any) any {
|
||||
switch v := value.(type) {
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(v))
|
||||
for key, item := range v {
|
||||
out[key] = normalizeManifestValue(item)
|
||||
}
|
||||
return out
|
||||
case map[any]any:
|
||||
out := make(map[string]any, len(v))
|
||||
for key, item := range v {
|
||||
out[fmt.Sprint(key)] = normalizeManifestValue(item)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, len(v))
|
||||
for i, item := range v {
|
||||
out[i] = normalizeManifestValue(item)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func manifestFormatForPath(filePath string) (string, error) {
|
||||
switch strings.ToLower(filepath.Base(filePath)) {
|
||||
case "manifest.json":
|
||||
return manifestFormatJSON, nil
|
||||
case "manifest.jsonc":
|
||||
return manifestFormatJSONC, nil
|
||||
case "manifest.yaml", "manifest.yml":
|
||||
return manifestFormatYAML, nil
|
||||
case "manifest.toml":
|
||||
return manifestFormatTOML, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported manifest file %q", filePath)
|
||||
}
|
||||
}
|
||||
|
||||
func isManifestSourceFile(filePath string) bool {
|
||||
_, err := manifestFormatForPath(filePath)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func stripJSONC(data []byte) ([]byte, error) {
|
||||
withoutComments, err := stripJSONCComments(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return stripJSONCTrailingCommas(withoutComments), nil
|
||||
}
|
||||
|
||||
func stripJSONCComments(data []byte) ([]byte, error) {
|
||||
out := make([]byte, 0, len(data))
|
||||
inString := false
|
||||
escaped := false
|
||||
for i := 0; i < len(data); i++ {
|
||||
ch := data[i]
|
||||
if inString {
|
||||
out = append(out, ch)
|
||||
if escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if ch == '\\' {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if ch == '"' {
|
||||
inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ch == '"' {
|
||||
inString = true
|
||||
out = append(out, ch)
|
||||
continue
|
||||
}
|
||||
if ch == '/' && i+1 < len(data) {
|
||||
next := data[i+1]
|
||||
if next == '/' {
|
||||
out = append(out, ' ', ' ')
|
||||
i += 2
|
||||
for ; i < len(data); i++ {
|
||||
if data[i] == '\n' || data[i] == '\r' {
|
||||
out = append(out, data[i])
|
||||
break
|
||||
}
|
||||
out = append(out, ' ')
|
||||
}
|
||||
continue
|
||||
}
|
||||
if next == '*' {
|
||||
out = append(out, ' ', ' ')
|
||||
i += 2
|
||||
closed := false
|
||||
for ; i < len(data); i++ {
|
||||
if data[i] == '*' && i+1 < len(data) && data[i+1] == '/' {
|
||||
out = append(out, ' ', ' ')
|
||||
i++
|
||||
closed = true
|
||||
break
|
||||
}
|
||||
if data[i] == '\n' || data[i] == '\r' {
|
||||
out = append(out, data[i])
|
||||
} else {
|
||||
out = append(out, ' ')
|
||||
}
|
||||
}
|
||||
if !closed {
|
||||
return nil, errors.New("unterminated block comment")
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, ch)
|
||||
}
|
||||
if inString {
|
||||
return nil, errors.New("unterminated string")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func stripJSONCTrailingCommas(data []byte) []byte {
|
||||
var out bytes.Buffer
|
||||
inString := false
|
||||
escaped := false
|
||||
for i := 0; i < len(data); i++ {
|
||||
ch := data[i]
|
||||
if inString {
|
||||
out.WriteByte(ch)
|
||||
if escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if ch == '\\' {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if ch == '"' {
|
||||
inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ch == '"' {
|
||||
inString = true
|
||||
out.WriteByte(ch)
|
||||
continue
|
||||
}
|
||||
if ch == ',' {
|
||||
j := i + 1
|
||||
for j < len(data) && isJSONWhitespace(data[j]) {
|
||||
j++
|
||||
}
|
||||
if j < len(data) && (data[j] == '}' || data[j] == ']') {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out.WriteByte(ch)
|
||||
}
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
func isJSONWhitespace(ch byte) bool {
|
||||
return ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r'
|
||||
}
|
||||
979
cmd/gateway/plugin_cli_remote.go
Normal file
979
cmd/gateway/plugin_cli_remote.go
Normal file
@@ -0,0 +1,979 @@
|
||||
// cmd/gateway/plugin_cli_remote.go 实现通过 Admin API 驱动插件管理操作的命令行客户端。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/tursom/mc-gateway/internal/pluginmanager"
|
||||
)
|
||||
|
||||
type pluginRemoteOptions struct {
|
||||
Gateway string
|
||||
Token string
|
||||
Target string
|
||||
Extra []string
|
||||
ArtifactID string
|
||||
ConfigPath string
|
||||
ConfigJSON string
|
||||
Priority int
|
||||
Source bool
|
||||
SnapshotID int64
|
||||
FullDesired bool
|
||||
Profile string
|
||||
Action string
|
||||
Decision string
|
||||
Notes string
|
||||
Reason string
|
||||
TTLSeconds int64
|
||||
ConfirmToken string
|
||||
DryRun bool
|
||||
RepositoryType string
|
||||
IndexPath string
|
||||
Version string
|
||||
TrustPolicy string
|
||||
MetadataPath string
|
||||
MetadataJSON string
|
||||
BenchmarkProfile string
|
||||
P95MS float64
|
||||
P99MS float64
|
||||
ErrorRate float64
|
||||
ActiveProxyCapacity int64
|
||||
BaselineDiff float64
|
||||
Mode string
|
||||
}
|
||||
|
||||
type pluginRemoteClient struct {
|
||||
baseURL string
|
||||
token string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func runPluginRemoteStatusCLI(args []string) error {
|
||||
opts, err := parsePluginRemoteOptions(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
endpoint := "/plugins"
|
||||
if opts.Target != "" {
|
||||
endpoint = "/plugins/" + url.PathEscape(opts.Target)
|
||||
}
|
||||
return client.doToStdout(http.MethodGet, endpoint, nil)
|
||||
}
|
||||
|
||||
func runPluginRemoteUploadCLI(args []string) error {
|
||||
opts, err := parsePluginRemoteOptions(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Target == "" {
|
||||
return errors.New("upload requires an artifact path")
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
endpoint := "/plugin-artifacts"
|
||||
if opts.Source {
|
||||
endpoint = "/plugin-sources"
|
||||
}
|
||||
return client.uploadArtifact(endpoint, opts.Target)
|
||||
}
|
||||
|
||||
func runPluginRemoteDesiredCLI(args []string, desiredState string) error {
|
||||
opts, err := parsePluginRemoteOptions(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Target == "" {
|
||||
return errors.New("enable requires a plugin id")
|
||||
}
|
||||
if opts.ArtifactID == "" {
|
||||
return errors.New("enable requires --artifact")
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
configJSON, err := remoteConfigJSON(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]any{
|
||||
"artifact_id": opts.ArtifactID,
|
||||
"desired_state": desiredState,
|
||||
"config_json": configJSON,
|
||||
"priority": opts.Priority,
|
||||
"source": "cli",
|
||||
"requested_mode": "desired",
|
||||
}
|
||||
return client.doToStdout(http.MethodPut, "/plugins/"+url.PathEscape(opts.Target), body)
|
||||
}
|
||||
|
||||
func runPluginRemoteActionCLI(args []string, action string) error {
|
||||
opts, err := parsePluginRemoteOptions(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Target == "" {
|
||||
return fmt.Errorf("%s requires a plugin id", action)
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/"+action, nil)
|
||||
}
|
||||
|
||||
func runPluginRemoteRollbackCLI(args []string) error {
|
||||
opts, err := parsePluginRemoteOptions(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Target == "" {
|
||||
return errors.New("rollback requires a plugin id")
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.SnapshotID > 0 {
|
||||
body := map[string]any{"snapshot_id": opts.SnapshotID, "full_desired": opts.FullDesired}
|
||||
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/rollback/config", body)
|
||||
}
|
||||
if opts.ArtifactID == "" {
|
||||
return errors.New("rollback requires --artifact or --snapshot")
|
||||
}
|
||||
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/rollback/artifact", map[string]any{"artifact_id": opts.ArtifactID})
|
||||
}
|
||||
|
||||
func runPluginRemoteConfigCLI(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("usage: gateway plugin config validate <plugin-id> ...")
|
||||
}
|
||||
switch args[0] {
|
||||
case "validate":
|
||||
opts, err := parsePluginRemoteOptions(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Target == "" {
|
||||
return errors.New("config validate requires a plugin id")
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
configJSON, err := remoteConfigJSON(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]any{"artifact_id": opts.ArtifactID, "config_json": configJSON}
|
||||
if opts.Priority != 0 {
|
||||
body["priority"] = opts.Priority
|
||||
}
|
||||
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/config/dry-run", body)
|
||||
default:
|
||||
return fmt.Errorf("unknown config command %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runPluginRemoteSecretCLI(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("usage: gateway plugin secret check <plugin-id> ...")
|
||||
}
|
||||
switch args[0] {
|
||||
case "check":
|
||||
opts, err := parsePluginRemoteOptions(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Target == "" {
|
||||
return errors.New("secret check requires a plugin id")
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return client.doToStdout(http.MethodGet, "/plugins/"+url.PathEscape(opts.Target)+"/secrets", nil)
|
||||
default:
|
||||
return fmt.Errorf("unknown secret command %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runPluginRemoteOperationsSectionCLI(command string, args []string) error {
|
||||
opts, err := parsePluginRemoteOptions(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Target == "" {
|
||||
return fmt.Errorf("%s requires a plugin id", command)
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := client.doJSON(http.MethodGet, "/plugins/"+url.PathEscape(opts.Target)+"/operations", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
operations, _ := body["operations"].(map[string]any)
|
||||
result := map[string]any{"plugin_id": opts.Target}
|
||||
switch command {
|
||||
case "logs":
|
||||
result["logs"] = operations["logs"]
|
||||
result["traces"] = operations["traces"]
|
||||
case "events":
|
||||
result["events"] = operations["events"]
|
||||
result["event_queue"] = operations["event_queue"]
|
||||
case "metrics":
|
||||
result["handlers"] = operations["handlers"]
|
||||
result["custom_metrics"] = operations["custom_metrics"]
|
||||
default:
|
||||
return fmt.Errorf("unknown operations section %q", command)
|
||||
}
|
||||
return encodePluginCLIJSON(result)
|
||||
}
|
||||
|
||||
func runPluginRemoteDiagnoseCLI(args []string) error {
|
||||
opts, err := parsePluginRemoteOptions(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Target == "" {
|
||||
return errors.New("diagnose requires a plugin id")
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return client.doToStdout(http.MethodGet, "/plugins/"+url.PathEscape(opts.Target)+"/diagnostics", nil)
|
||||
}
|
||||
|
||||
func runPluginRemoteTaskCLI(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("usage: gateway plugin task list|run|cancel ...")
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
opts, err := parsePluginRemoteOptions(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Target == "" {
|
||||
return errors.New("task list requires a plugin id")
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := client.doJSON(http.MethodGet, "/plugins/"+url.PathEscape(opts.Target)+"/operations", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
operations, _ := body["operations"].(map[string]any)
|
||||
return encodePluginCLIJSON(map[string]any{
|
||||
"plugin_id": opts.Target,
|
||||
"background_tasks": operations["background_tasks"],
|
||||
})
|
||||
case "run":
|
||||
opts, err := parsePluginRemoteOptionsWithPositionals(args[1:], 2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Target == "" || len(opts.Extra) == 0 {
|
||||
return errors.New("task run requires a plugin id and task id")
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
taskID := opts.Extra[0]
|
||||
body := map[string]any{"confirm_token": opts.ConfirmToken}
|
||||
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/operations/tasks/"+url.PathEscape(taskID)+"/trigger", body)
|
||||
case "cancel":
|
||||
return runPluginReservedCLI("task cancel", args[1:])
|
||||
default:
|
||||
return fmt.Errorf("unknown task command %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runPluginRemoteResourceCLI(command string, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("usage: gateway plugin %s inspect|gc ...", command)
|
||||
}
|
||||
switch args[0] {
|
||||
case "inspect":
|
||||
opts, err := parsePluginRemoteOptions(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Target == "" {
|
||||
return fmt.Errorf("%s inspect requires a plugin id", command)
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := client.doJSON(http.MethodGet, "/plugins/"+url.PathEscape(opts.Target)+"/operations", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
operations, _ := body["operations"].(map[string]any)
|
||||
field := "plugin_data"
|
||||
if command == "files" {
|
||||
field = "plugin_files"
|
||||
}
|
||||
return encodePluginCLIJSON(map[string]any{
|
||||
"plugin_id": opts.Target,
|
||||
field: operations[field],
|
||||
"gc": operations["gc"],
|
||||
})
|
||||
case "gc":
|
||||
opts, err := parsePluginRemoteOptions(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Target == "" {
|
||||
return fmt.Errorf("%s gc requires a plugin id", command)
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
method := http.MethodGet
|
||||
if !opts.DryRun {
|
||||
method = http.MethodPost
|
||||
}
|
||||
return client.doToStdout(method, "/plugins/"+url.PathEscape(opts.Target)+"/operations/gc", nil)
|
||||
case "export":
|
||||
return runPluginReservedCLI(command+" export", args[1:])
|
||||
default:
|
||||
return fmt.Errorf("unknown %s command %q", command, args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runPluginRemoteGCCLI(args []string) error {
|
||||
opts, err := parsePluginRemoteOptions(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
method := http.MethodGet
|
||||
if !opts.DryRun {
|
||||
method = http.MethodPost
|
||||
}
|
||||
endpoint := "/plugin-gc"
|
||||
if opts.Target != "" {
|
||||
endpoint = "/plugin-operations-gc?plugin_id=" + url.QueryEscape(opts.Target)
|
||||
}
|
||||
return client.doToStdout(method, endpoint, nil)
|
||||
}
|
||||
|
||||
func runPluginRemoteReviewCLI(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("usage: gateway plugin review status|approve|reject|override ...")
|
||||
}
|
||||
switch args[0] {
|
||||
case "status":
|
||||
opts, err := parsePluginRemoteOptions(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Target == "" {
|
||||
return errors.New("review status requires a plugin id")
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
values := url.Values{}
|
||||
if opts.ArtifactID != "" {
|
||||
values.Set("artifact_id", opts.ArtifactID)
|
||||
}
|
||||
if opts.Profile != "" {
|
||||
values.Set("profile", opts.Profile)
|
||||
}
|
||||
endpoint := "/plugins/" + url.PathEscape(opts.Target) + "/governance"
|
||||
if query := values.Encode(); query != "" {
|
||||
endpoint += "?" + query
|
||||
}
|
||||
return client.doToStdout(http.MethodGet, endpoint, nil)
|
||||
case "approve", "reject":
|
||||
opts, err := parsePluginRemoteOptions(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Target == "" {
|
||||
return fmt.Errorf("review %s requires a plugin id", args[0])
|
||||
}
|
||||
if opts.ArtifactID == "" {
|
||||
return fmt.Errorf("review %s requires --artifact", args[0])
|
||||
}
|
||||
decision := pluginmanager.ReviewDecisionApproved
|
||||
if args[0] == "reject" {
|
||||
decision = pluginmanager.ReviewDecisionRejected
|
||||
}
|
||||
if opts.Decision != "" {
|
||||
decision = opts.Decision
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]any{
|
||||
"artifact_id": opts.ArtifactID,
|
||||
"profile": opts.Profile,
|
||||
"decision": decision,
|
||||
"notes": opts.Notes,
|
||||
}
|
||||
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/governance/review", body)
|
||||
case "override":
|
||||
opts, err := parsePluginRemoteOptions(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Target == "" || opts.ArtifactID == "" {
|
||||
return errors.New("review override requires a plugin id and --artifact")
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]any{
|
||||
"artifact_id": opts.ArtifactID,
|
||||
"profile": opts.Profile,
|
||||
"action": opts.Action,
|
||||
"reason": opts.Reason,
|
||||
"ttl_seconds": opts.TTLSeconds,
|
||||
}
|
||||
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/governance/override", body)
|
||||
default:
|
||||
return fmt.Errorf("unknown review command %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runPluginRemoteAdvisoryCLI(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("usage: gateway plugin advisory scan|import ...")
|
||||
}
|
||||
switch args[0] {
|
||||
case "scan":
|
||||
opts, err := parsePluginRemoteOptions(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
endpoint := "/plugin-advisories"
|
||||
if opts.Target != "" {
|
||||
endpoint += "?plugin_id=" + url.QueryEscape(opts.Target)
|
||||
}
|
||||
return client.doToStdout(http.MethodGet, endpoint, nil)
|
||||
case "import":
|
||||
opts, err := parsePluginRemoteOptions(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := remoteMetadataJSON(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return client.doToStdout(http.MethodPost, "/plugin-advisories", body)
|
||||
default:
|
||||
return fmt.Errorf("unknown advisory command %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runPluginRemoteRepoCLI(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("usage: gateway plugin repo list|import|search|show ...")
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
opts, err := parsePluginRemoteOptions(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return client.doToStdout(http.MethodGet, "/plugin-repositories/imports", nil)
|
||||
case "import":
|
||||
opts, err := parsePluginRemoteOptions(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]any{
|
||||
"repository_type": opts.RepositoryType,
|
||||
"index_path": opts.IndexPath,
|
||||
"artifact_id": opts.ArtifactID,
|
||||
"plugin_id": opts.Target,
|
||||
"version": opts.Version,
|
||||
"trust_policy": opts.TrustPolicy,
|
||||
}
|
||||
return client.doToStdout(http.MethodPost, "/plugin-repositories/imports", body)
|
||||
case "search", "show":
|
||||
return runPluginReservedCLI("repo "+args[0], args[1:])
|
||||
default:
|
||||
return fmt.Errorf("unknown repo command %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runPluginRemoteSupplyChainCLI(command string, args []string) error {
|
||||
if command == "sbom" {
|
||||
if len(args) == 0 {
|
||||
return errors.New("usage: gateway plugin sbom verify|generate ...")
|
||||
}
|
||||
switch args[0] {
|
||||
case "verify":
|
||||
return runPluginRemoteSupplyChainAssessCLI(args[1:])
|
||||
case "generate":
|
||||
return runPluginReservedCLI("sbom generate", args[1:])
|
||||
default:
|
||||
return fmt.Errorf("unknown sbom command %q", args[0])
|
||||
}
|
||||
}
|
||||
return runPluginRemoteSupplyChainAssessCLI(args)
|
||||
}
|
||||
|
||||
func runPluginRemoteSupplyChainAssessCLI(args []string) error {
|
||||
opts, err := parsePluginRemoteOptions(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Target == "" || opts.ArtifactID == "" {
|
||||
return errors.New("supply-chain verification requires a plugin id and --artifact")
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
metadata, err := remoteMetadataJSON(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]any{
|
||||
"plugin_id": opts.Target,
|
||||
"artifact_id": opts.ArtifactID,
|
||||
"metadata": metadata,
|
||||
}
|
||||
return client.doToStdout(http.MethodPost, "/plugin-supply-chain", body)
|
||||
}
|
||||
|
||||
func runPluginRuntimeCLI(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("usage: gateway plugin runtime features|status|mode|apply ...")
|
||||
}
|
||||
switch args[0] {
|
||||
case "features":
|
||||
return runPluginFeaturesCLI(nil)
|
||||
case "status":
|
||||
opts, err := parsePluginRemoteOptions(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return client.doToStdout(http.MethodGet, "/plugin-service", nil)
|
||||
case "mode":
|
||||
opts, err := parsePluginRemoteOptions(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Mode == "" {
|
||||
return errors.New("runtime mode requires --mode")
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return client.doToStdout(http.MethodPut, "/plugin-service", map[string]any{"desired_mode": opts.Mode})
|
||||
case "apply":
|
||||
opts, err := parsePluginRemoteOptions(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := newPluginRemoteClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return client.doToStdout(http.MethodPost, "/plugin-service", nil)
|
||||
default:
|
||||
return fmt.Errorf("unknown runtime command %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runPluginReservedCLI(command string, args []string) error {
|
||||
_ = args
|
||||
return encodePluginCLIJSON(map[string]any{
|
||||
"command": command,
|
||||
"status": "reserved",
|
||||
"message": "command is reserved by the plugin toolchain design but is not implemented in this gateway yet",
|
||||
})
|
||||
}
|
||||
|
||||
func parsePluginRemoteOptions(args []string) (pluginRemoteOptions, error) {
|
||||
return parsePluginRemoteOptionsWithPositionals(args, 1)
|
||||
}
|
||||
|
||||
func parsePluginRemoteOptionsWithPositionals(args []string, maxPositionals int) (pluginRemoteOptions, error) {
|
||||
opts := pluginRemoteOptions{
|
||||
Gateway: os.Getenv("MC_GATEWAY_ADMIN_URL"),
|
||||
Token: os.Getenv("MC_GATEWAY_ADMIN_TOKEN"),
|
||||
Priority: pluginmanager.DefaultPriority,
|
||||
DryRun: true,
|
||||
}
|
||||
var positionals []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
if !strings.HasPrefix(arg, "--") {
|
||||
if len(positionals) >= maxPositionals {
|
||||
return pluginRemoteOptions{}, fmt.Errorf("unexpected argument %q", arg)
|
||||
}
|
||||
positionals = append(positionals, arg)
|
||||
continue
|
||||
}
|
||||
key, value, consumed, err := parsePluginCLIFlag(args, i)
|
||||
if err != nil {
|
||||
return pluginRemoteOptions{}, err
|
||||
}
|
||||
i += consumed
|
||||
switch key {
|
||||
case "gateway":
|
||||
opts.Gateway = value
|
||||
case "token":
|
||||
opts.Token = value
|
||||
case "artifact":
|
||||
opts.ArtifactID = value
|
||||
case "config":
|
||||
opts.ConfigPath = value
|
||||
case "config-json":
|
||||
opts.ConfigJSON = value
|
||||
case "priority":
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return pluginRemoteOptions{}, fmt.Errorf("invalid --priority %q: %w", value, err)
|
||||
}
|
||||
opts.Priority = parsed
|
||||
case "source":
|
||||
opts.Source = parsePluginBoolFlag(value)
|
||||
case "snapshot":
|
||||
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return pluginRemoteOptions{}, fmt.Errorf("invalid --snapshot %q: %w", value, err)
|
||||
}
|
||||
opts.SnapshotID = parsed
|
||||
case "full-desired":
|
||||
opts.FullDesired = parsePluginBoolFlag(value)
|
||||
case "profile":
|
||||
opts.Profile = value
|
||||
case "action":
|
||||
opts.Action = value
|
||||
case "decision":
|
||||
opts.Decision = value
|
||||
case "notes":
|
||||
opts.Notes = value
|
||||
case "reason":
|
||||
opts.Reason = value
|
||||
case "ttl":
|
||||
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return pluginRemoteOptions{}, fmt.Errorf("invalid --ttl %q: %w", value, err)
|
||||
}
|
||||
opts.TTLSeconds = parsed
|
||||
case "confirm-token":
|
||||
opts.ConfirmToken = value
|
||||
case "dry-run":
|
||||
opts.DryRun = parsePluginBoolFlag(value)
|
||||
case "repository-type":
|
||||
opts.RepositoryType = value
|
||||
case "index":
|
||||
opts.IndexPath = value
|
||||
case "version":
|
||||
opts.Version = value
|
||||
case "trust-policy":
|
||||
opts.TrustPolicy = value
|
||||
case "metadata":
|
||||
opts.MetadataPath = value
|
||||
case "metadata-json":
|
||||
opts.MetadataJSON = value
|
||||
case "benchmark-profile":
|
||||
opts.BenchmarkProfile = value
|
||||
case "p95-ms":
|
||||
parsed, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
return pluginRemoteOptions{}, fmt.Errorf("invalid --p95-ms %q: %w", value, err)
|
||||
}
|
||||
opts.P95MS = parsed
|
||||
case "p99-ms":
|
||||
parsed, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
return pluginRemoteOptions{}, fmt.Errorf("invalid --p99-ms %q: %w", value, err)
|
||||
}
|
||||
opts.P99MS = parsed
|
||||
case "error-rate":
|
||||
parsed, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
return pluginRemoteOptions{}, fmt.Errorf("invalid --error-rate %q: %w", value, err)
|
||||
}
|
||||
opts.ErrorRate = parsed
|
||||
case "active-proxy-capacity":
|
||||
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return pluginRemoteOptions{}, fmt.Errorf("invalid --active-proxy-capacity %q: %w", value, err)
|
||||
}
|
||||
opts.ActiveProxyCapacity = parsed
|
||||
case "baseline-diff":
|
||||
parsed, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
return pluginRemoteOptions{}, fmt.Errorf("invalid --baseline-diff %q: %w", value, err)
|
||||
}
|
||||
opts.BaselineDiff = parsed
|
||||
case "mode":
|
||||
opts.Mode = value
|
||||
default:
|
||||
return pluginRemoteOptions{}, fmt.Errorf("unknown remote flag --%s", key)
|
||||
}
|
||||
}
|
||||
if len(positionals) > 0 {
|
||||
opts.Target = positionals[0]
|
||||
}
|
||||
if len(positionals) > 1 {
|
||||
opts.Extra = append(opts.Extra, positionals[1:]...)
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func newPluginRemoteClient(opts pluginRemoteOptions) (pluginRemoteClient, error) {
|
||||
if strings.TrimSpace(opts.Gateway) == "" {
|
||||
return pluginRemoteClient{}, errors.New("--gateway or MC_GATEWAY_ADMIN_URL is required")
|
||||
}
|
||||
if strings.TrimSpace(opts.Token) == "" {
|
||||
return pluginRemoteClient{}, errors.New("--token or MC_GATEWAY_ADMIN_TOKEN is required")
|
||||
}
|
||||
base, err := normalizeAdminAPIBase(opts.Gateway)
|
||||
if err != nil {
|
||||
return pluginRemoteClient{}, err
|
||||
}
|
||||
return pluginRemoteClient{baseURL: base, token: opts.Token, client: http.DefaultClient}, nil
|
||||
}
|
||||
|
||||
func normalizeAdminAPIBase(raw string) (string, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if parsed.Scheme == "" || parsed.Host == "" {
|
||||
return "", fmt.Errorf("invalid gateway URL %q", raw)
|
||||
}
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
||||
switch {
|
||||
case parsed.Path == "":
|
||||
parsed.Path = "/admin/api"
|
||||
case strings.HasSuffix(parsed.Path, "/admin/api"):
|
||||
case strings.HasSuffix(parsed.Path, "/admin"):
|
||||
parsed.Path = parsed.Path + "/api"
|
||||
default:
|
||||
parsed.Path = path.Join(parsed.Path, "admin/api")
|
||||
}
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func (c pluginRemoteClient) doToStdout(method, endpoint string, body any) error {
|
||||
data, err := c.doBytes(method, endpoint, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writePluginRemoteData(data)
|
||||
}
|
||||
|
||||
func (c pluginRemoteClient) doJSON(method, endpoint string, body any) (map[string]any, error) {
|
||||
data, err := c.doBytes(method, endpoint, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
return nil, fmt.Errorf("admin API response is not a JSON object: %w", err)
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
func (c pluginRemoteClient) doBytes(method, endpoint string, body any) ([]byte, error) {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reader = bytes.NewReader(data)
|
||||
}
|
||||
req, err := http.NewRequest(method, c.baseURL+endpoint, reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return readPluginRemoteResponse(resp)
|
||||
}
|
||||
|
||||
func (c pluginRemoteClient) uploadArtifact(endpoint, filePath string) error {
|
||||
var payload bytes.Buffer
|
||||
writer := multipart.NewWriter(&payload)
|
||||
part, err := writer.CreateFormFile("artifact", filepath.Base(filePath))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(part, file); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, c.baseURL+endpoint, &payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := readPluginRemoteResponse(resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writePluginRemoteData(data)
|
||||
}
|
||||
|
||||
func readPluginRemoteResponse(resp *http.Response) ([]byte, error) {
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
message := strings.TrimSpace(string(data))
|
||||
if message == "" {
|
||||
message = resp.Status
|
||||
}
|
||||
return nil, fmt.Errorf("admin API %s: %s", resp.Status, message)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func writePluginRemoteData(data []byte) error {
|
||||
if len(data) == 0 {
|
||||
fmt.Fprintln(os.Stdout, "{}")
|
||||
return nil
|
||||
}
|
||||
var pretty bytes.Buffer
|
||||
if json.Indent(&pretty, data, "", " ") == nil {
|
||||
pretty.WriteByte('\n')
|
||||
_, err := pretty.WriteTo(os.Stdout)
|
||||
return err
|
||||
}
|
||||
_, err := os.Stdout.Write(data)
|
||||
if err == nil && len(data) > 0 && data[len(data)-1] != '\n' {
|
||||
fmt.Fprintln(os.Stdout)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func remoteConfigJSON(opts pluginRemoteOptions) (string, error) {
|
||||
if opts.ConfigJSON != "" {
|
||||
if !json.Valid([]byte(opts.ConfigJSON)) {
|
||||
return "", errors.New("--config-json must be valid JSON")
|
||||
}
|
||||
return opts.ConfigJSON, nil
|
||||
}
|
||||
if opts.ConfigPath != "" {
|
||||
data, err := os.ReadFile(opts.ConfigPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !json.Valid(data) {
|
||||
return "", fmt.Errorf("config file %q must contain valid JSON", opts.ConfigPath)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
return "{}", nil
|
||||
}
|
||||
|
||||
func remoteMetadataJSON(opts pluginRemoteOptions) (map[string]any, error) {
|
||||
if opts.MetadataJSON != "" {
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal([]byte(opts.MetadataJSON), &decoded); err != nil {
|
||||
return nil, fmt.Errorf("--metadata-json must be a JSON object: %w", err)
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
if opts.MetadataPath != "" {
|
||||
data, err := os.ReadFile(opts.MetadataPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
return nil, fmt.Errorf("metadata file %q must contain a JSON object: %w", opts.MetadataPath, err)
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
1909
cmd/gateway/plugin_cli_toolchain.go
Normal file
1909
cmd/gateway/plugin_cli_toolchain.go
Normal file
File diff suppressed because it is too large
Load Diff
552
cmd/gateway/plugin_cli_toolchain_test.go
Normal file
552
cmd/gateway/plugin_cli_toolchain_test.go
Normal file
@@ -0,0 +1,552 @@
|
||||
// cmd/gateway/plugin_cli_toolchain_test.go 包含用于约束 plugin cli toolchain 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPluginInitCreatesBuildableTemplate(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "sample-plugin")
|
||||
handled, code := runPluginCLI([]string{
|
||||
"plugin", "init", dir,
|
||||
"--id", "sample-plugin",
|
||||
"--module", "example.com/sample-plugin",
|
||||
})
|
||||
if !handled {
|
||||
t.Fatal("runPluginCLI() handled = false")
|
||||
}
|
||||
if code != 0 {
|
||||
t.Fatalf("runPluginCLI(init) code = %d, want 0", code)
|
||||
}
|
||||
for _, name := range []string{"manifest.yaml", "go.mod", "main.go", "main_test.go", "README.md", "testdata/config.json"} {
|
||||
if _, err := os.Stat(filepath.Join(dir, filepath.FromSlash(name))); err != nil {
|
||||
t.Fatalf("generated file %s stat error = %v", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "manifest.json")); err == nil {
|
||||
t.Fatal("plugin init generated manifest.json by default, want manifest.yaml")
|
||||
}
|
||||
if _, err := validatePluginDirectoryForCLI(dir, ""); err != nil {
|
||||
t.Fatalf("validatePluginDirectoryForCLI() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginBuildSourcePackagesTemplate(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "source-plugin")
|
||||
handled, code := runPluginCLI([]string{
|
||||
"plugin", "init", dir,
|
||||
"--id", "source-plugin",
|
||||
"--module", "example.com/source-plugin",
|
||||
})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(init) = (%v, %d), want handled code 0", handled, code)
|
||||
}
|
||||
out := filepath.Join(t.TempDir(), "source-plugin.mcgp")
|
||||
handled, code = runPluginCLI([]string{
|
||||
"plugin", "build", dir,
|
||||
"--type", "source",
|
||||
"--out", out,
|
||||
"--skip-tests",
|
||||
"--vendor=false",
|
||||
})
|
||||
if !handled {
|
||||
t.Fatal("runPluginCLI() handled = false")
|
||||
}
|
||||
if code != 0 {
|
||||
t.Fatalf("runPluginCLI(build source) code = %d, want 0", code)
|
||||
}
|
||||
if _, err := validatePluginPathForCLI(out, "source"); err != nil {
|
||||
t.Fatalf("validatePluginPathForCLI(source) error = %v", err)
|
||||
}
|
||||
assertZipContains(t, out, "manifest.json", "go.mod", "main.go", "main_test.go", "README.md", "testdata/config.json")
|
||||
assertZipNotContains(t, out, "manifest.yaml")
|
||||
}
|
||||
|
||||
func TestPluginBuildBothAcceptsOutDirectory(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "both-plugin")
|
||||
handled, code := runPluginCLI([]string{
|
||||
"plugin", "init", dir,
|
||||
"--id", "both-plugin",
|
||||
"--module", "example.com/both-plugin",
|
||||
})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(init) = (%v, %d), want handled code 0", handled, code)
|
||||
}
|
||||
outDir := filepath.Join(t.TempDir(), "packages")
|
||||
handled, code = runPluginCLI([]string{
|
||||
"plugin", "build", dir,
|
||||
"--type", "both",
|
||||
"--out", outDir,
|
||||
"--skip-tests",
|
||||
"--vendor=false",
|
||||
})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(build both) = (%v, %d), want handled code 0", handled, code)
|
||||
}
|
||||
binaryOut := filepath.Join(outDir, "both-plugin.mcgp")
|
||||
sourceOut := filepath.Join(outDir, "both-plugin-source.mcgp")
|
||||
if _, err := validatePluginPathForCLI(binaryOut, "binary"); err != nil {
|
||||
t.Fatalf("validatePluginPathForCLI(binary) error = %v", err)
|
||||
}
|
||||
if _, err := validatePluginPathForCLI(sourceOut, "source"); err != nil {
|
||||
t.Fatalf("validatePluginPathForCLI(source) error = %v", err)
|
||||
}
|
||||
assertZipNotContains(t, binaryOut, "manifest.yaml")
|
||||
assertZipNotContains(t, sourceOut, "manifest.yaml")
|
||||
}
|
||||
|
||||
func TestPluginTestManifestProfile(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "test-plugin")
|
||||
handled, code := runPluginCLI([]string{
|
||||
"plugin", "init", dir,
|
||||
"--id", "test-plugin",
|
||||
"--module", "example.com/test-plugin",
|
||||
})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(init) = (%v, %d), want handled code 0", handled, code)
|
||||
}
|
||||
handled, code = runPluginCLI([]string{"plugin", "test", dir, "--profile", "manifest"})
|
||||
if !handled {
|
||||
t.Fatal("runPluginCLI() handled = false")
|
||||
}
|
||||
if code != 0 {
|
||||
t.Fatalf("runPluginCLI(test manifest) code = %d, want 0", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginFeaturesAndManifestCommands(t *testing.T) {
|
||||
handled, code := runPluginCLI([]string{"plugin", "features"})
|
||||
if !handled {
|
||||
t.Fatal("runPluginCLI() handled = false")
|
||||
}
|
||||
if code != 0 {
|
||||
t.Fatalf("runPluginCLI(features) code = %d, want 0", code)
|
||||
}
|
||||
handled, code = runPluginCLI([]string{"plugin", "manifest", "explain", "upstream.connect/v1"})
|
||||
if !handled {
|
||||
t.Fatal("runPluginCLI() handled = false")
|
||||
}
|
||||
if code != 0 {
|
||||
t.Fatalf("runPluginCLI(manifest explain) code = %d, want 0", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginManifestFormatWrite(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "format-plugin")
|
||||
handled, code := runPluginCLI([]string{
|
||||
"plugin", "init", dir,
|
||||
"--id", "format-plugin",
|
||||
"--module", "example.com/format-plugin",
|
||||
"--manifest-format", "json",
|
||||
})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(init) = (%v, %d), want handled code 0", handled, code)
|
||||
}
|
||||
manifestPath := filepath.Join(dir, "manifest.json")
|
||||
if err := os.WriteFile(manifestPath, []byte(`{"schema_version":"mc-gateway.plugin/v1","id":"format-plugin","name":"Format Plugin","version":"0.1.0","artifact_type":"source","runtime":{"type":"go-plugin","entry":"plugin.so","entry_symbol":"Plugin"},"build":{"type":"go","entry":".","output":"plugin.so"},"api_version":"plugin-api/v1","sdk_module":"github.com/tursom/mc-gateway/plugin/api","sdk_module_version":"v0.1.0","extension_points":[{"type":"hook","key":"upstream.connect/v1"}],"capabilities":{"upstream_connect":{"mode":"dialer"}},"runtime_limits":{"handler_timeout_ms":3000},"config_schema":{"type":"object"}}`), 0644); err != nil {
|
||||
t.Fatalf("WriteFile(manifest) error = %v", err)
|
||||
}
|
||||
handled, code = runPluginCLI([]string{"plugin", "manifest", "format", dir, "--write"})
|
||||
if !handled {
|
||||
t.Fatal("runPluginCLI() handled = false")
|
||||
}
|
||||
if code != 0 {
|
||||
t.Fatalf("runPluginCLI(manifest format) code = %d, want 0", code)
|
||||
}
|
||||
data, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(manifest) error = %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "\n \"schema_version\"") {
|
||||
t.Fatalf("manifest was not formatted:\n%s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginManifestSourceFormats(t *testing.T) {
|
||||
for _, format := range []string{"yaml", "toml", "jsonc", "json"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "format-"+format)
|
||||
handled, code := runPluginCLI([]string{
|
||||
"plugin", "init", dir,
|
||||
"--id", "format-" + format,
|
||||
"--module", "example.com/format-" + format,
|
||||
"--manifest-format", format,
|
||||
})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(init %s) = (%v, %d), want handled code 0", format, handled, code)
|
||||
}
|
||||
source, err := readPluginManifestSource(dir, "")
|
||||
if err != nil {
|
||||
t.Fatalf("readPluginManifestSource(%s) error = %v", format, err)
|
||||
}
|
||||
if source.Manifest.ID != "format-"+format {
|
||||
t.Fatalf("manifest id = %q, want format-%s", source.Manifest.ID, format)
|
||||
}
|
||||
if !json.Valid(source.CanonicalJSON) {
|
||||
t.Fatalf("canonical JSON for %s is invalid:\n%s", format, source.CanonicalJSON)
|
||||
}
|
||||
packaged, err := materializedManifestJSON(source.Raw, source.Manifest, "binary", false)
|
||||
if err != nil {
|
||||
t.Fatalf("materializedManifestJSON(%s) error = %v", format, err)
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(packaged, &raw); err != nil {
|
||||
t.Fatalf("Unmarshal(materialized %s) error = %v", format, err)
|
||||
}
|
||||
if raw["artifact_type"] != "binary" || raw["go_version"] == "" || raw["go_os"] == "" || raw["go_arch"] == "" {
|
||||
t.Fatalf("materialized %s manifest missing package fields: %s", format, packaged)
|
||||
}
|
||||
handled, code = runPluginCLI([]string{"plugin", "validate", dir})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(validate %s) = (%v, %d), want handled code 0", format, handled, code)
|
||||
}
|
||||
handled, code = runPluginCLI([]string{"plugin", "test", dir, "--profile", "manifest"})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(test %s) = (%v, %d), want handled code 0", format, handled, code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginManifestFormatWritePreservesComments(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
file string
|
||||
content string
|
||||
comment string
|
||||
}{
|
||||
{
|
||||
name: "yaml",
|
||||
file: "manifest.yaml",
|
||||
content: "# keep yaml comment\n" + manifestYAMLTemplate(pluginInitCLIOptions{ID: "comment-yaml", Name: "Comment YAML", Extension: "upstream.connect/v1"}),
|
||||
comment: "# keep yaml comment",
|
||||
},
|
||||
{
|
||||
name: "toml",
|
||||
file: "manifest.toml",
|
||||
content: "# keep toml comment\n" + manifestTOMLTemplate(pluginInitCLIOptions{ID: "comment-toml", Name: "Comment TOML", Extension: "upstream.connect/v1"}),
|
||||
comment: "# keep toml comment",
|
||||
},
|
||||
{
|
||||
name: "jsonc",
|
||||
file: "manifest.jsonc",
|
||||
content: strings.Replace(
|
||||
"// keep jsonc comment\n"+strings.TrimSuffix(manifestSourceJSONTemplate(pluginInitCLIOptions{ID: "comment-jsonc", Name: "Comment JSONC", Extension: "upstream.connect/v1"}, true), "\n"),
|
||||
"\n }\n}",
|
||||
"\n },\n}",
|
||||
1,
|
||||
),
|
||||
comment: "// keep jsonc comment",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
manifestPath := filepath.Join(dir, tc.file)
|
||||
if err := os.WriteFile(manifestPath, []byte(tc.content), 0644); err != nil {
|
||||
t.Fatalf("WriteFile(%s) error = %v", tc.file, err)
|
||||
}
|
||||
before, err := readPluginManifestSource(dir, "")
|
||||
if err != nil {
|
||||
t.Fatalf("readPluginManifestSource(before) error = %v", err)
|
||||
}
|
||||
handled, code := runPluginCLI([]string{"plugin", "manifest", "format", dir, "--write"})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(manifest format %s) = (%v, %d), want handled code 0", tc.name, handled, code)
|
||||
}
|
||||
data, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", tc.file, err)
|
||||
}
|
||||
if !strings.Contains(string(data), tc.comment) {
|
||||
t.Fatalf("formatted %s lost comment:\n%s", tc.file, data)
|
||||
}
|
||||
after, err := readPluginManifestSource(dir, "")
|
||||
if err != nil {
|
||||
t.Fatalf("readPluginManifestSource(after) error = %v", err)
|
||||
}
|
||||
if !bytes.Equal(before.CanonicalJSON, after.CanonicalJSON) {
|
||||
t.Fatalf("canonical JSON changed after format\nbefore=%s\nafter=%s", before.CanonicalJSON, after.CanonicalJSON)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginManifestMultipleSourcesRequireExplicitManifest(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "multi-manifest")
|
||||
handled, code := runPluginCLI([]string{
|
||||
"plugin", "init", dir,
|
||||
"--id", "multi-manifest",
|
||||
"--module", "example.com/multi-manifest",
|
||||
})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(init) = (%v, %d), want handled code 0", handled, code)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifestSourceJSONTemplate(pluginInitCLIOptions{ID: "multi-manifest", Name: "Multi Manifest", Extension: "upstream.connect/v1"}, false)), 0644); err != nil {
|
||||
t.Fatalf("WriteFile(manifest.json) error = %v", err)
|
||||
}
|
||||
handled, code = runPluginCLI([]string{"plugin", "validate", dir})
|
||||
if !handled {
|
||||
t.Fatal("runPluginCLI(validate) handled = false")
|
||||
}
|
||||
if code == 0 {
|
||||
t.Fatal("runPluginCLI(validate) code = 0, want failure for multiple manifests")
|
||||
}
|
||||
handled, code = runPluginCLI([]string{"plugin", "validate", dir, "--manifest", "manifest.yaml"})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(validate --manifest) = (%v, %d), want handled code 0", handled, code)
|
||||
}
|
||||
handled, code = runPluginCLI([]string{"plugin", "test", dir, "--profile", "manifest", "--manifest", "manifest.yaml"})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(test --manifest) = (%v, %d), want handled code 0", handled, code)
|
||||
}
|
||||
out := filepath.Join(t.TempDir(), "multi-manifest-source.mcgp")
|
||||
handled, code = runPluginCLI([]string{"plugin", "build", dir, "--type", "source", "--out", out, "--skip-tests", "--vendor=false", "--manifest", "manifest.yaml"})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(build --manifest) = (%v, %d), want handled code 0", handled, code)
|
||||
}
|
||||
if _, err := validatePluginPathForCLI(out, "source"); err != nil {
|
||||
t.Fatalf("validatePluginPathForCLI(source) error = %v", err)
|
||||
}
|
||||
handled, code = runPluginCLI([]string{"plugin", "manifest", "format", dir, "--manifest", "manifest.yaml", "--canonical-json", "--type", "source"})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(manifest format --manifest --canonical-json) = (%v, %d), want handled code 0", handled, code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginGovernanceCommands(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "governance-plugin")
|
||||
handled, code := runPluginCLI([]string{
|
||||
"plugin", "init", dir,
|
||||
"--id", "governance-plugin",
|
||||
"--module", "example.com/governance-plugin",
|
||||
})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(init) = (%v, %d), want handled code 0", handled, code)
|
||||
}
|
||||
artifact := filepath.Join(t.TempDir(), "governance-plugin.mcgp")
|
||||
handled, code = runPluginCLI([]string{
|
||||
"plugin", "build", dir,
|
||||
"--type", "binary",
|
||||
"--out", artifact,
|
||||
"--skip-tests",
|
||||
})
|
||||
if !handled || code != 0 {
|
||||
t.Fatalf("runPluginCLI(build binary) = (%v, %d), want handled code 0", handled, code)
|
||||
}
|
||||
for _, tc := range [][]string{
|
||||
{"plugin", "preflight", artifact, "--config-json", `{"upstream":"127.0.0.1:25566"}`, "--profile", "dev"},
|
||||
{"plugin", "self-test", artifact, "--profile", "dev"},
|
||||
{"plugin", "benchmark", artifact, "--profile", "dev", "--benchmark-profile", "local-fast", "--p95-ms", "1", "--p99-ms", "2", "--error-rate", "0", "--baseline-diff", "0.1"},
|
||||
{"plugin", "preflight", dir, "--manifest", "manifest.yaml", "--config-json", `{"upstream":"127.0.0.1:25566"}`, "--profile", "dev"},
|
||||
} {
|
||||
handled, code = runPluginCLI(tc)
|
||||
if !handled {
|
||||
t.Fatalf("runPluginCLI(%v) handled = false", tc)
|
||||
}
|
||||
if code != 0 {
|
||||
t.Fatalf("runPluginCLI(%v) code = %d, want 0", tc, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginRemoteCLIRequests(t *testing.T) {
|
||||
type observedRequest struct {
|
||||
Method string
|
||||
RequestURI string
|
||||
ContentType string
|
||||
Body map[string]any
|
||||
FileName string
|
||||
}
|
||||
requests := make(chan observedRequest, 16)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
|
||||
t.Errorf("Authorization = %q, want bearer token", got)
|
||||
}
|
||||
observed := observedRequest{
|
||||
Method: r.Method,
|
||||
RequestURI: r.URL.RequestURI(),
|
||||
ContentType: r.Header.Get("Content-Type"),
|
||||
Body: map[string]any{},
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(observed.ContentType, "application/json"):
|
||||
if err := json.NewDecoder(r.Body).Decode(&observed.Body); err != nil {
|
||||
t.Errorf("Decode JSON body error = %v", err)
|
||||
}
|
||||
case strings.HasPrefix(observed.ContentType, "multipart/form-data"):
|
||||
if err := r.ParseMultipartForm(64 << 20); err != nil {
|
||||
t.Errorf("ParseMultipartForm error = %v", err)
|
||||
} else {
|
||||
file, header, err := r.FormFile("artifact")
|
||||
if err != nil {
|
||||
t.Errorf("FormFile(artifact) error = %v", err)
|
||||
} else {
|
||||
observed.FileName = header.Filename
|
||||
_, _ = io.Copy(io.Discard, file)
|
||||
_ = file.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
requests <- observed
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if strings.HasSuffix(r.URL.Path, "/operations") {
|
||||
_, _ = io.WriteString(w, `{"operations":{"logs":[{"message":"ok"}],"traces":[],"events":[{"name":"evt"}],"event_queue":{"queued":1},"handlers":[{"plugin_id":"demo"}],"custom_metrics":[],"background_tasks":[{"id":"sync"}],"plugin_data":[{"key":"k"}],"plugin_files":[{"name":"f"}],"gc":[]}}`)
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"ok":true}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv("MC_GATEWAY_ADMIN_URL", server.URL)
|
||||
t.Setenv("MC_GATEWAY_ADMIN_TOKEN", "test-token")
|
||||
|
||||
runRemotePluginCLI(t, "plugin", "status", "demo")
|
||||
assertRemoteRequest(t, <-requests, http.MethodGet, "/admin/api/plugins/demo")
|
||||
|
||||
artifactPath := filepath.Join(t.TempDir(), "demo.mcgp")
|
||||
if err := os.WriteFile(artifactPath, []byte("artifact"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile(artifact) error = %v", err)
|
||||
}
|
||||
runRemotePluginCLI(t, "plugin", "upload", artifactPath)
|
||||
uploadReq := <-requests
|
||||
assertRemoteRequest(t, uploadReq, http.MethodPost, "/admin/api/plugin-artifacts")
|
||||
if uploadReq.FileName != "demo.mcgp" {
|
||||
t.Fatalf("upload file name = %q, want demo.mcgp", uploadReq.FileName)
|
||||
}
|
||||
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"upstream":"127.0.0.1:25565"}`), 0644); err != nil {
|
||||
t.Fatalf("WriteFile(config) error = %v", err)
|
||||
}
|
||||
runRemotePluginCLI(t, "plugin", "enable", "demo", "--artifact", "art-1", "--config", configPath, "--priority", "7")
|
||||
enableReq := <-requests
|
||||
assertRemoteRequest(t, enableReq, http.MethodPut, "/admin/api/plugins/demo")
|
||||
if enableReq.Body["artifact_id"] != "art-1" || enableReq.Body["desired_state"] != "enabled" || enableReq.Body["priority"].(float64) != 7 {
|
||||
t.Fatalf("enable body = %#v", enableReq.Body)
|
||||
}
|
||||
|
||||
runRemotePluginCLI(t, "plugin", "logs", "demo")
|
||||
assertRemoteRequest(t, <-requests, http.MethodGet, "/admin/api/plugins/demo/operations")
|
||||
|
||||
runRemotePluginCLI(t, "plugin", "task", "run", "demo", "sync", "--confirm-token", "confirm")
|
||||
taskReq := <-requests
|
||||
assertRemoteRequest(t, taskReq, http.MethodPost, "/admin/api/plugins/demo/operations/tasks/sync/trigger")
|
||||
if taskReq.Body["confirm_token"] != "confirm" {
|
||||
t.Fatalf("task body = %#v", taskReq.Body)
|
||||
}
|
||||
|
||||
runRemotePluginCLI(t, "plugin", "repo", "import", "demo", "--repository-type", "file", "--index", "repo.json", "--artifact", "candidate-1", "--version", "1.2.3")
|
||||
repoReq := <-requests
|
||||
assertRemoteRequest(t, repoReq, http.MethodPost, "/admin/api/plugin-repositories/imports")
|
||||
if repoReq.Body["plugin_id"] != "demo" || repoReq.Body["repository_type"] != "file" || repoReq.Body["artifact_id"] != "candidate-1" {
|
||||
t.Fatalf("repo body = %#v", repoReq.Body)
|
||||
}
|
||||
|
||||
runRemotePluginCLI(t, "plugin", "review", "status", "demo", "--artifact", "art-1", "--profile", "prod")
|
||||
assertRemoteRequest(t, <-requests, http.MethodGet, "/admin/api/plugins/demo/governance?artifact_id=art-1&profile=prod")
|
||||
|
||||
runRemotePluginCLI(t, "plugin", "sbom", "verify", "demo", "--artifact", "art-1", "--metadata-json", `{"sbom":{"format":"spdx"}}`)
|
||||
supplyReq := <-requests
|
||||
assertRemoteRequest(t, supplyReq, http.MethodPost, "/admin/api/plugin-supply-chain")
|
||||
if supplyReq.Body["plugin_id"] != "demo" || supplyReq.Body["artifact_id"] != "art-1" {
|
||||
t.Fatalf("supply-chain body = %#v", supplyReq.Body)
|
||||
}
|
||||
|
||||
runRemotePluginCLI(t, "plugin", "runtime", "mode", "--mode", "go-plugin-process")
|
||||
runtimeReq := <-requests
|
||||
assertRemoteRequest(t, runtimeReq, http.MethodPut, "/admin/api/plugin-service")
|
||||
if runtimeReq.Body["desired_mode"] != "go-plugin-process" {
|
||||
t.Fatalf("runtime body = %#v", runtimeReq.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAdminAPIBase(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{raw: "http://127.0.0.1:8080", want: "http://127.0.0.1:8080/admin/api"},
|
||||
{raw: "http://127.0.0.1:8080/admin", want: "http://127.0.0.1:8080/admin/api"},
|
||||
{raw: "http://127.0.0.1:8080/admin/api/", want: "http://127.0.0.1:8080/admin/api"},
|
||||
} {
|
||||
got, err := normalizeAdminAPIBase(tc.raw)
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeAdminAPIBase(%q) error = %v", tc.raw, err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("normalizeAdminAPIBase(%q) = %q, want %q", tc.raw, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runRemotePluginCLI(t *testing.T, args ...string) {
|
||||
t.Helper()
|
||||
handled, code := runPluginCLI(args)
|
||||
if !handled {
|
||||
t.Fatalf("runPluginCLI(%v) handled = false", args)
|
||||
}
|
||||
if code != 0 {
|
||||
t.Fatalf("runPluginCLI(%v) code = %d, want 0", args, code)
|
||||
}
|
||||
}
|
||||
|
||||
func assertRemoteRequest(t *testing.T, got struct {
|
||||
Method string
|
||||
RequestURI string
|
||||
ContentType string
|
||||
Body map[string]any
|
||||
FileName string
|
||||
}, wantMethod, wantURI string) {
|
||||
t.Helper()
|
||||
if got.Method != wantMethod || got.RequestURI != wantURI {
|
||||
t.Fatalf("request = %s %s, want %s %s", got.Method, got.RequestURI, wantMethod, wantURI)
|
||||
}
|
||||
}
|
||||
|
||||
func assertZipContains(t *testing.T, zipPath string, names ...string) {
|
||||
t.Helper()
|
||||
reader, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenReader(%s) error = %v", zipPath, err)
|
||||
}
|
||||
defer reader.Close()
|
||||
seen := make(map[string]bool, len(reader.File))
|
||||
for _, file := range reader.File {
|
||||
seen[file.Name] = true
|
||||
if strings.Contains(file.Name, `\`) {
|
||||
t.Fatalf("zip entry %q uses backslash", file.Name)
|
||||
}
|
||||
}
|
||||
for _, name := range names {
|
||||
if !seen[name] {
|
||||
t.Fatalf("zip %s missing entry %s; entries=%v", zipPath, name, seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertZipNotContains(t *testing.T, zipPath string, names ...string) {
|
||||
t.Helper()
|
||||
reader, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenReader(%s) error = %v", zipPath, err)
|
||||
}
|
||||
defer reader.Close()
|
||||
seen := make(map[string]bool, len(reader.File))
|
||||
for _, file := range reader.File {
|
||||
seen[file.Name] = true
|
||||
}
|
||||
for _, name := range names {
|
||||
if seen[name] {
|
||||
t.Fatalf("zip %s unexpectedly contains entry %s", zipPath, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
284
cmd/gateway/plugin_test.go
Normal file
284
cmd/gateway/plugin_test.go
Normal file
@@ -0,0 +1,284 @@
|
||||
// cmd/gateway/plugin_test.go 包含用于约束 plugin 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/tursom/mc-gateway/plugin/api"
|
||||
)
|
||||
|
||||
func TestGatewayHookStoresHandler(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
pluginLock.Lock()
|
||||
hooks["plugin-a"] = make(map[string]any)
|
||||
pluginLock.Unlock()
|
||||
|
||||
handler := "handler"
|
||||
if err := (&Gateway{pluginId: "plugin-a"}).Hook("hook-a", handler); err != nil {
|
||||
t.Fatalf("Hook() error = %v", err)
|
||||
}
|
||||
if got := hooks["plugin-a"]["hook-a"]; got != handler {
|
||||
t.Fatalf("stored hook = %v, want %v", got, handler)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerAdaptors(t *testing.T) {
|
||||
if got := Handler1[int, bool](7)(func(v int) bool { return v == 7 }); !got {
|
||||
t.Fatal("Handler1 did not pass its argument")
|
||||
}
|
||||
if got := Handler2[int, string, bool](7, "x")(func(v int, s string) bool {
|
||||
return v == 7 && s == "x"
|
||||
}); !got {
|
||||
t.Fatal("Handler2 did not pass its arguments")
|
||||
}
|
||||
gotString, gotBool := Handler1R2[int, string, bool](7)(func(v int) (string, bool) {
|
||||
return "ok", v == 7
|
||||
})
|
||||
if gotString != "ok" || !gotBool {
|
||||
t.Fatalf("Handler1R2() = (%q, %v), want (ok, true)", gotString, gotBool)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvokeFirstHookHandler(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
source := newGatewayTestConn(nil)
|
||||
upstream := newGatewayTestConn(nil)
|
||||
registerGatewayUpstreamHook(
|
||||
t,
|
||||
func(gotSource net.Conn, host string) bool {
|
||||
return gotSource == source && host == "backend.example:25565"
|
||||
},
|
||||
func(net.Conn, string) (net.Conn, error) {
|
||||
return upstream, nil
|
||||
},
|
||||
)
|
||||
|
||||
var handled bool
|
||||
ok, err := invokeFirstHookHandler(
|
||||
api.HookUpstream,
|
||||
Handler2[net.Conn, string, bool](source, "backend.example:25565"),
|
||||
func(handler func(net.Conn, string) (net.Conn, error)) error {
|
||||
got, err := handler(source, "backend.example:25565")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
handled = got == upstream
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("invokeFirstHookHandler() error = %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("invokeFirstHookHandler() ok = false, want true")
|
||||
}
|
||||
if !handled {
|
||||
t.Fatal("first matching handler was not invoked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvokeFirstHookHandlerNoMatch(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
registerGatewayUpstreamHook(
|
||||
t,
|
||||
func(net.Conn, string) bool { return false },
|
||||
func(net.Conn, string) (net.Conn, error) {
|
||||
t.Fatal("handler should not be invoked")
|
||||
return nil, nil
|
||||
},
|
||||
)
|
||||
|
||||
ok, err := invokeFirstHookHandler(
|
||||
api.HookUpstream,
|
||||
Handler2[net.Conn, string, bool](newGatewayTestConn(nil), "backend.example:25565"),
|
||||
func(func(net.Conn, string) (net.Conn, error)) error {
|
||||
t.Fatal("callback should not be invoked")
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("invokeFirstHookHandler() error = %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("invokeFirstHookHandler() ok = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvokeFirstHookHandlerReturnsCallbackError(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
wantErr := errors.New("callback failed")
|
||||
registerGatewayUpstreamHook(
|
||||
t,
|
||||
func(net.Conn, string) bool { return true },
|
||||
func(net.Conn, string) (net.Conn, error) { return nil, nil },
|
||||
)
|
||||
|
||||
ok, err := invokeFirstHookHandler(
|
||||
api.HookUpstream,
|
||||
Handler2[net.Conn, string, bool](newGatewayTestConn(nil), "backend.example:25565"),
|
||||
func(func(net.Conn, string) (net.Conn, error)) error {
|
||||
return wantErr
|
||||
},
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("invokeFirstHookHandler() ok = false, want true")
|
||||
}
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("invokeFirstHookHandler() error = %v, want %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvokeAllHookHandler(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
for _, pluginID := range []string{"plugin-a", "plugin-b"} {
|
||||
pluginLock.Lock()
|
||||
hooks[pluginID] = make(map[string]any)
|
||||
pluginLock.Unlock()
|
||||
if err := api.RegisterHookHandler(
|
||||
&Gateway{pluginId: pluginID},
|
||||
api.HookUpstream,
|
||||
func(net.Conn, string) bool { return true },
|
||||
func(net.Conn, string) (net.Conn, error) { return nil, nil },
|
||||
); err != nil {
|
||||
t.Fatalf("RegisterHookHandler(%s) error = %v", pluginID, err)
|
||||
}
|
||||
}
|
||||
|
||||
calls := 0
|
||||
err := invokeAllHookHandler(
|
||||
api.HookUpstream,
|
||||
Handler2[net.Conn, string, bool](newGatewayTestConn(nil), "backend.example:25565"),
|
||||
func(func(net.Conn, string) (net.Conn, error)) error {
|
||||
calls++
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("invokeAllHookHandler() error = %v", err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("invokeAllHookHandler() calls = %d, want 2", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvokeAllHookHandlerReturnsCallbackError(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
wantErr := errors.New("callback failed")
|
||||
registerGatewayUpstreamHook(
|
||||
t,
|
||||
func(net.Conn, string) bool { return true },
|
||||
func(net.Conn, string) (net.Conn, error) { return nil, nil },
|
||||
)
|
||||
|
||||
err := invokeAllHookHandler(
|
||||
api.HookUpstream,
|
||||
Handler2[net.Conn, string, bool](newGatewayTestConn(nil), "backend.example:25565"),
|
||||
func(func(net.Conn, string) (net.Conn, error)) error {
|
||||
return wantErr
|
||||
},
|
||||
)
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("invokeAllHookHandler() error = %v, want %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPluginsDisablesExistingPlugin(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
plugin := &gatewayPluginStub{}
|
||||
plugins["plugin-a"] = plugin
|
||||
hooks["plugin-a"] = map[string]any{"hook": "handler"}
|
||||
config.Plugin = map[string]map[string]any{
|
||||
"plugin-a": {"enable": false},
|
||||
}
|
||||
|
||||
loadPlugins()
|
||||
|
||||
if plugin.destroyCalls != 1 {
|
||||
t.Fatalf("Destroy() calls = %d, want 1", plugin.destroyCalls)
|
||||
}
|
||||
if _, ok := plugins["plugin-a"]; ok {
|
||||
t.Fatal("disabled plugin was not removed")
|
||||
}
|
||||
if _, ok := hooks["plugin-a"]; ok {
|
||||
t.Fatal("disabled plugin hooks were not removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPluginsSkipsAlreadyLoadedEnabledPlugin(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
plugin := &gatewayPluginStub{}
|
||||
plugins["plugin-a"] = plugin
|
||||
hooks["plugin-a"] = make(map[string]any)
|
||||
config.Plugin = map[string]map[string]any{
|
||||
"plugin-a": {"enable": true, "file": "missing-plugin-file"},
|
||||
}
|
||||
|
||||
loadPlugins()
|
||||
|
||||
if plugins["plugin-a"] != plugin {
|
||||
t.Fatal("existing enabled plugin was replaced")
|
||||
}
|
||||
if plugin.destroyCalls != 0 {
|
||||
t.Fatalf("Destroy() calls = %d, want 0", plugin.destroyCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPluginsIgnoresMissingPluginFile(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
config.Plugin = map[string]map[string]any{
|
||||
"plugin-a": {"enable": true, "file": "missing-plugin-file"},
|
||||
}
|
||||
|
||||
loadPlugins()
|
||||
|
||||
if _, ok := plugins["plugin-a"]; ok {
|
||||
t.Fatal("missing plugin file should not be registered")
|
||||
}
|
||||
if _, ok := hooks["plugin-a"]; ok {
|
||||
t.Fatal("missing plugin file should not create hooks")
|
||||
}
|
||||
}
|
||||
|
||||
type gatewayPluginStub struct {
|
||||
destroyCalls int
|
||||
}
|
||||
|
||||
func (p *gatewayPluginStub) Init(api.Gateway) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *gatewayPluginStub) Destroy() error {
|
||||
p.destroyCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *gatewayPluginStub) NewConfigObj() any {
|
||||
return &struct{}{}
|
||||
}
|
||||
|
||||
func (p *gatewayPluginStub) ReloadConfig(any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestGatewayExitWaitGroup(t *testing.T) {
|
||||
if got := (&Gateway{}).ExitWaitGroup(); got != &exitWaitGroup {
|
||||
t.Fatalf("ExitWaitGroup() = %p, want %p", got, &exitWaitGroup)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayPluginStubSatisfiesInterface(t *testing.T) {
|
||||
var _ api.Plugin = (*gatewayPluginStub)(nil)
|
||||
var _ api.Gateway = (*Gateway)(nil)
|
||||
}
|
||||
193
cmd/gateway/quic.go
Normal file
193
cmd/gateway/quic.go
Normal file
@@ -0,0 +1,193 @@
|
||||
// cmd/gateway/quic.go 启动可选的 QUIC 监听器,并把 QUIC 流适配到普通网关连接流程。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
quic "github.com/quic-go/quic-go"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type (
|
||||
// quicConn 把 QUIC connection 和单条 stream 组合成 net.Conn 风格对象,
|
||||
// 使后续转发逻辑不用区分 TCP 与 QUIC。
|
||||
quicConn struct {
|
||||
quic.Connection
|
||||
quic.Stream
|
||||
}
|
||||
)
|
||||
|
||||
func runQuic(wg *sync.WaitGroup) {
|
||||
if wg != nil {
|
||||
defer wg.Done()
|
||||
}
|
||||
|
||||
// QUIC 基于 UDP 监听,端口来自运行态服务配置。
|
||||
udpConn, err := net.ListenUDP("udp4", &net.UDPAddr{Port: config.Quic.Port})
|
||||
if err != nil {
|
||||
log.Panic().Err(err).Msg("Failed to listen UDP")
|
||||
}
|
||||
defer udpConn.Close()
|
||||
|
||||
tlsConf, err := generateTLSConfig()
|
||||
if err != nil {
|
||||
log.Panic().Err(err).Msg("Failed to generate TLS config")
|
||||
}
|
||||
|
||||
// quic-go 的 listener 接收 connection,真正的字节流在 stream 中。
|
||||
ln, err := quic.Listen(udpConn, tlsConf, nil)
|
||||
if err != nil {
|
||||
log.Panic().Err(err).Msg("Failed to listen QUIC")
|
||||
}
|
||||
log.Info().Int("port", config.Quic.Port).Msg("Listening for QUIC connections")
|
||||
|
||||
for {
|
||||
conn, err := ln.Accept(context.Background())
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Error accepting QUIC connection")
|
||||
continue
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("client", conn.RemoteAddr().String()).
|
||||
Msg("Accepted QUIC connection")
|
||||
go handleQuicRequest(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func upstreamQuic(host string) net.Conn {
|
||||
tlsConf := &tls.Config{
|
||||
// 网关自管的 QUIC 上游默认使用临时证书,当前先跳过证书校验。
|
||||
InsecureSkipVerify: true, // 跳过证书检查
|
||||
NextProtos: getQuicNextProtos(),
|
||||
}
|
||||
|
||||
// 上游握手使用短超时,避免连接协程在不可达上游上长期等待。
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
conn, err := quic.DialAddr(ctx, host, tlsConf, nil)
|
||||
if err != nil {
|
||||
gatewayMetrics.UpstreamDialError()
|
||||
log.Err(err).Str("host", host).Msg("Failed to dial QUIC")
|
||||
return nil
|
||||
}
|
||||
|
||||
stream, err := conn.OpenStream()
|
||||
if err != nil {
|
||||
gatewayMetrics.UpstreamDialError()
|
||||
log.Err(err).Str("host", host).Msg("Failed to open stream")
|
||||
conn.CloseWithError(0, "failed to open stream")
|
||||
return nil
|
||||
}
|
||||
log.Debug().Str("host", host).Msg("QUIC stream opened")
|
||||
|
||||
// 返回的 quicConn 后续会收到 Minecraft 首包回放并进入普通双向转发。
|
||||
return quicConn{
|
||||
Connection: conn,
|
||||
Stream: stream,
|
||||
}
|
||||
}
|
||||
|
||||
func handleQuicRequest(conn quic.Connection) {
|
||||
defer conn.CloseWithError(0, "Closing connection")
|
||||
|
||||
// 入口连接只等待第一条 stream;该 stream 承载完整 Minecraft 字节流。
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
||||
defer cancel()
|
||||
|
||||
stream, err := conn.AcceptStream(ctx)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Error accepting stream")
|
||||
return
|
||||
}
|
||||
|
||||
handleRequest(quicConn{
|
||||
Connection: conn,
|
||||
Stream: stream,
|
||||
})
|
||||
}
|
||||
|
||||
func generateTLSConfig() (*tls.Config, error) {
|
||||
// 生成临时私钥;当前 QUIC 入口不依赖磁盘证书文件。
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 创建自签证书模板,满足 QUIC TLS 握手要求。
|
||||
template := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"Example Org"},
|
||||
},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(365 * 24 * time.Hour), // 有效期 1 年。
|
||||
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
|
||||
// 自签名证书用于当前进程生命周期内的 QUIC 监听。
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 编码证书和私钥,再交给 tls.X509KeyPair 解析为标准证书结构。
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
keyPEM, err := x509.MarshalECPrivateKey(priv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keyPEMBlock := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyPEM})
|
||||
|
||||
// 加载到 tls.Certificate。
|
||||
cert, err := tls.X509KeyPair(certPEM, keyPEMBlock)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 返回 QUIC listener 使用的 TLS 配置。
|
||||
return &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
NextProtos: getQuicNextProtos(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getQuicNextProtos() []string {
|
||||
nextProtos := config.Quic.ApplicationProtocols
|
||||
if len(nextProtos) == 0 {
|
||||
return []string{"minecraft", "quic", "raw", "h3"} // 默认协议列表。
|
||||
}
|
||||
return nextProtos
|
||||
}
|
||||
|
||||
func (c quicConn) Close() error {
|
||||
_ = c.Stream.Close()
|
||||
return c.Connection.CloseWithError(0, "Closing QUIC connection")
|
||||
}
|
||||
|
||||
func (c quicConn) CloseWrite() error {
|
||||
// QUIC stream 关闭写方向即可通知对端没有更多数据。
|
||||
return c.Stream.Close()
|
||||
}
|
||||
|
||||
func (c quicConn) CloseRead() error {
|
||||
// CancelRead 用于停止接收方向,匹配 relay.go 中的半关闭调用。
|
||||
c.Stream.CancelRead(0)
|
||||
return nil
|
||||
}
|
||||
53
cmd/gateway/quic_test.go
Normal file
53
cmd/gateway/quic_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// cmd/gateway/quic_test.go 包含用于约束 quic 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetQuicNextProtos(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
wantDefault := []string{"minecraft", "quic", "raw", "h3"}
|
||||
if got := getQuicNextProtos(); !reflect.DeepEqual(got, wantDefault) {
|
||||
t.Fatalf("getQuicNextProtos() = %v, want %v", got, wantDefault)
|
||||
}
|
||||
|
||||
config.Quic.ApplicationProtocols = []string{"minecraft", "custom"}
|
||||
if got := getQuicNextProtos(); !reflect.DeepEqual(got, config.Quic.ApplicationProtocols) {
|
||||
t.Fatalf("getQuicNextProtos() = %v, want %v", got, config.Quic.ApplicationProtocols)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTLSConfig(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
config.Quic.ApplicationProtocols = []string{"minecraft", "custom"}
|
||||
tlsConfig, err := generateTLSConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("generateTLSConfig() error = %v", err)
|
||||
}
|
||||
if len(tlsConfig.Certificates) != 1 {
|
||||
t.Fatalf("certificates len = %d, want 1", len(tlsConfig.Certificates))
|
||||
}
|
||||
if !reflect.DeepEqual(tlsConfig.NextProtos, config.Quic.ApplicationProtocols) {
|
||||
t.Fatalf("NextProtos = %v, want %v", tlsConfig.NextProtos, config.Quic.ApplicationProtocols)
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(tlsConfig.Certificates[0].Certificate[0])
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCertificate() error = %v", err)
|
||||
}
|
||||
if !cert.IsCA && !cert.BasicConstraintsValid {
|
||||
t.Fatal("generated certificate has invalid basic constraints")
|
||||
}
|
||||
if len(cert.ExtKeyUsage) != 1 || cert.ExtKeyUsage[0] != x509.ExtKeyUsageServerAuth {
|
||||
t.Fatalf("ExtKeyUsage = %v, want server auth", cert.ExtKeyUsage)
|
||||
}
|
||||
if !cert.NotAfter.After(cert.NotBefore) {
|
||||
t.Fatalf("certificate validity range is invalid: %v - %v", cert.NotBefore, cert.NotAfter)
|
||||
}
|
||||
}
|
||||
137
cmd/gateway/relay.go
Normal file
137
cmd/gateway/relay.go
Normal file
@@ -0,0 +1,137 @@
|
||||
// cmd/gateway/relay.go 实现客户端与上游之间的双向复制循环和转发缓冲池。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const proxyBufferSize = 64 * 1024
|
||||
|
||||
// proxyBufferPool 为普通 io.CopyBuffer 路径复用 64KiB 缓冲区,降低长连接转发时的分配压力。
|
||||
var proxyBufferPool = sync.Pool{
|
||||
New: func() any {
|
||||
buf := make([]byte, proxyBufferSize)
|
||||
return &buf
|
||||
},
|
||||
}
|
||||
|
||||
type (
|
||||
closeWriter interface {
|
||||
CloseWrite() error
|
||||
}
|
||||
|
||||
closeReader interface {
|
||||
CloseRead() error
|
||||
}
|
||||
)
|
||||
|
||||
func proxyConnections(a, b io.ReadWriter) {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// 两个方向独立复制,任意一侧读到 EOF 后通过半关闭通知对端。
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
proxyCopy(b, a)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
proxyCopy(a, b)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func proxyCopy(dst io.Writer, src io.Reader) {
|
||||
// 转发协程不能把 panic 带出到连接处理主协程;记录后关闭对应方向即可。
|
||||
defer recoverProxyCopy()
|
||||
defer closeRead(src)
|
||||
defer closeWrite(dst)
|
||||
|
||||
_, err := copyForward(dst, src)
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
log.Debug().Err(err).Msg("proxy copy stopped")
|
||||
}
|
||||
}
|
||||
|
||||
func copyForward(dst io.Writer, src io.Reader) (int64, error) {
|
||||
// 优先使用标准库为具体类型提供的零拷贝/优化路径,只有普通 reader/writer
|
||||
// 才落到共享缓冲区。
|
||||
if _, ok := src.(io.WriterTo); ok {
|
||||
return io.Copy(dst, src)
|
||||
}
|
||||
if _, ok := dst.(io.ReaderFrom); ok {
|
||||
return io.Copy(dst, src)
|
||||
}
|
||||
|
||||
buf := getProxyBuffer()
|
||||
defer putProxyBuffer(buf)
|
||||
|
||||
return io.CopyBuffer(dst, src, buf)
|
||||
}
|
||||
|
||||
func getProxyBuffer() []byte {
|
||||
return *proxyBufferPool.Get().(*[]byte)
|
||||
}
|
||||
|
||||
func putProxyBuffer(buf []byte) {
|
||||
if cap(buf) != proxyBufferSize {
|
||||
return
|
||||
}
|
||||
buf = buf[:proxyBufferSize]
|
||||
proxyBufferPool.Put(&buf)
|
||||
}
|
||||
|
||||
func writeAll(w io.Writer, buf []byte) error {
|
||||
// net.Conn.Write 允许短写;首包回放和 PROXY 头写入必须循环直到写完。
|
||||
for len(buf) > 0 {
|
||||
n, err := w.Write(buf)
|
||||
if n > 0 {
|
||||
buf = buf[n:]
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func closeWrite(conn any) {
|
||||
// TCP 支持半关闭时只关闭写方向,让反向复制还有机会读完剩余数据。
|
||||
if closer, ok := conn.(closeWriter); ok {
|
||||
if err := closer.CloseWrite(); err != nil {
|
||||
log.Debug().Err(err).Msg("failed to close write side")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if closer, ok := conn.(io.Closer); ok {
|
||||
if err := closer.Close(); err != nil {
|
||||
log.Debug().Err(err).Msg("failed to close connection")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func closeRead(conn any) {
|
||||
// 支持 CloseRead 的连接可以显式停止读方向,帮助对端更快感知转发结束。
|
||||
if closer, ok := conn.(closeReader); ok {
|
||||
if err := closer.CloseRead(); err != nil {
|
||||
log.Debug().Err(err).Msg("failed to close read side")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func recoverProxyCopy() {
|
||||
if rec := recover(); rec != nil {
|
||||
log.Error().Any("panic", rec).Msg("panic in proxy copy")
|
||||
}
|
||||
}
|
||||
427
cmd/gateway/relay_benchmark_test.go
Normal file
427
cmd/gateway/relay_benchmark_test.go
Normal file
@@ -0,0 +1,427 @@
|
||||
// cmd/gateway/relay_benchmark_test.go 包含用于约束 relay benchmark 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/tursom/mc-gateway/plugin/api"
|
||||
)
|
||||
|
||||
const benchmarkPayloadSize = 4 << 20
|
||||
|
||||
func BenchmarkForwardCopy(b *testing.B) {
|
||||
disableBenchmarkLogs(b)
|
||||
|
||||
b.Run("stdlib_io_copy", func(b *testing.B) {
|
||||
benchmarkCopy(b, func(dst io.Writer, src io.Reader) {
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
b.Run("pooled_copy_buffer", func(b *testing.B) {
|
||||
benchmarkCopy(b, func(dst io.Writer, src io.Reader) {
|
||||
proxyCopy(dst, src)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkTCPForwardCopy(b *testing.B) {
|
||||
disableBenchmarkLogs(b)
|
||||
|
||||
b.Run("generic_user_buffer", func(b *testing.B) {
|
||||
benchmarkTCPForward(b, func(dst, src *net.TCPConn) error {
|
||||
defer closeRead(src)
|
||||
defer closeWrite(dst)
|
||||
|
||||
buf := getProxyBuffer()
|
||||
defer putProxyBuffer(buf)
|
||||
|
||||
_, err := io.CopyBuffer(
|
||||
benchmarkTCPWriterOnly{conn: dst},
|
||||
benchmarkTCPReaderOnly{conn: src},
|
||||
buf,
|
||||
)
|
||||
return err
|
||||
})
|
||||
})
|
||||
|
||||
b.Run("tcp_fast_path", func(b *testing.B) {
|
||||
benchmarkTCPForward(b, func(dst, src *net.TCPConn) error {
|
||||
proxyCopy(dst, src)
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkMapToHostInitialPacket(b *testing.B) {
|
||||
disableBenchmarkLogs(b)
|
||||
|
||||
const (
|
||||
hostName = "dev.example"
|
||||
upstreamHost = "benchmark-upstream"
|
||||
)
|
||||
|
||||
packet := benchmarkHandshakePacket(hostName)
|
||||
source := newBenchmarkConn(benchmarkAddr("client:25565"))
|
||||
upstream := newBenchmarkConn(benchmarkAddr("upstream:25565"))
|
||||
publishRouteSnapshot(map[string]string{hostName: upstreamHost})
|
||||
defer publishRouteSnapshot(nil)
|
||||
|
||||
restore := installBenchmarkUpstreamHook(b, hostName, upstreamHost, upstream)
|
||||
defer restore()
|
||||
|
||||
b.SetBytes(int64(len(packet)))
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
source.ResetReader(packet)
|
||||
upstream.ResetWriter()
|
||||
|
||||
client := mapToHost(source)
|
||||
if client == nil {
|
||||
b.Fatal("mapToHost returned nil")
|
||||
}
|
||||
if upstream.Written() != int64(len(packet)) {
|
||||
b.Fatalf("upstream wrote %d bytes, want %d", upstream.Written(), len(packet))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func benchmarkCopy(b *testing.B, copyFunc func(io.Writer, io.Reader)) {
|
||||
reader := newBenchmarkReader(benchmarkPayloadSize)
|
||||
writer := &benchmarkWriter{}
|
||||
|
||||
b.SetBytes(benchmarkPayloadSize)
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
reader.Reset(benchmarkPayloadSize)
|
||||
writer.Reset()
|
||||
|
||||
copyFunc(writer, reader)
|
||||
if writer.Written() != benchmarkPayloadSize {
|
||||
b.Fatalf("copied %d bytes, want %d", writer.Written(), benchmarkPayloadSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func benchmarkTCPForward(b *testing.B, copyFunc func(dst, src *net.TCPConn) error) {
|
||||
b.SetBytes(benchmarkPayloadSize)
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
b.StopTimer()
|
||||
|
||||
srcRelay, srcWriter := newBenchmarkTCPConnPair(b)
|
||||
dstReader, dstRelay := newBenchmarkTCPConnPair(b)
|
||||
setBenchmarkTCPDeadline(b, srcRelay, srcWriter, dstReader, dstRelay)
|
||||
|
||||
start := make(chan struct{})
|
||||
writerDone := make(chan error, 1)
|
||||
readerDone := make(chan benchmarkTCPReadResult, 1)
|
||||
|
||||
go benchmarkTCPWrite(start, srcWriter, writerDone)
|
||||
go benchmarkTCPRead(start, dstReader, readerDone)
|
||||
|
||||
b.StartTimer()
|
||||
close(start)
|
||||
|
||||
copyErr := copyFunc(dstRelay, srcRelay)
|
||||
writeErr := <-writerDone
|
||||
readResult := <-readerDone
|
||||
|
||||
b.StopTimer()
|
||||
closeBenchmarkTCPConns(srcRelay, srcWriter, dstReader, dstRelay)
|
||||
|
||||
if copyErr != nil && !errors.Is(copyErr, io.EOF) {
|
||||
b.Fatalf("copy failed: %v", copyErr)
|
||||
}
|
||||
if writeErr != nil {
|
||||
b.Fatalf("source write failed: %v", writeErr)
|
||||
}
|
||||
if readResult.err != nil {
|
||||
b.Fatalf("destination read failed: %v", readResult.err)
|
||||
}
|
||||
if readResult.n != benchmarkPayloadSize {
|
||||
b.Fatalf("forwarded %d bytes, want %d", readResult.n, benchmarkPayloadSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func benchmarkTCPWrite(start <-chan struct{}, conn *net.TCPConn, done chan<- error) {
|
||||
<-start
|
||||
|
||||
reader := newBenchmarkReader(benchmarkPayloadSize)
|
||||
buf := getProxyBuffer()
|
||||
_, err := io.CopyBuffer(conn, reader, buf)
|
||||
putProxyBuffer(buf)
|
||||
|
||||
if closeErr := conn.CloseWrite(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
done <- err
|
||||
}
|
||||
|
||||
func benchmarkTCPRead(start <-chan struct{}, conn *net.TCPConn, done chan<- benchmarkTCPReadResult) {
|
||||
<-start
|
||||
|
||||
n, err := io.Copy(io.Discard, conn)
|
||||
done <- benchmarkTCPReadResult{n: n, err: err}
|
||||
}
|
||||
|
||||
func benchmarkHandshakePacket(host string) []byte {
|
||||
packet := make([]byte, 5+len(host)+2)
|
||||
packet[4] = byte(len(host))
|
||||
copy(packet[5:], host)
|
||||
return packet
|
||||
}
|
||||
|
||||
func installBenchmarkUpstreamHook(b *testing.B, hostName, upstreamHost string, upstream net.Conn) func() {
|
||||
b.Helper()
|
||||
|
||||
previousConfig := config
|
||||
previousHooks := hooks
|
||||
|
||||
pluginLock.Lock()
|
||||
hooks = map[string]map[string]any{
|
||||
"benchmark": {},
|
||||
}
|
||||
pluginLock.Unlock()
|
||||
|
||||
gateway := &Gateway{pluginId: "benchmark"}
|
||||
if err := api.RegisterHookHandler(
|
||||
gateway,
|
||||
api.HookUpstream,
|
||||
func(_ net.Conn, host string) bool {
|
||||
return host == upstreamHost
|
||||
},
|
||||
func(_ net.Conn, _ string) (net.Conn, error) {
|
||||
return upstream, nil
|
||||
},
|
||||
); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
return func() {
|
||||
config = previousConfig
|
||||
|
||||
pluginLock.Lock()
|
||||
hooks = previousHooks
|
||||
pluginLock.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func newBenchmarkTCPConnPair(b *testing.B) (*net.TCPConn, *net.TCPConn) {
|
||||
b.Helper()
|
||||
|
||||
listener, err := net.ListenTCP("tcp4", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
accepted := make(chan *net.TCPConn, 1)
|
||||
acceptErr := make(chan error, 1)
|
||||
go func() {
|
||||
conn, err := listener.AcceptTCP()
|
||||
if err != nil {
|
||||
acceptErr <- err
|
||||
return
|
||||
}
|
||||
accepted <- conn
|
||||
}()
|
||||
|
||||
client, err := net.DialTCP("tcp4", nil, listener.Addr().(*net.TCPAddr))
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-acceptErr:
|
||||
client.Close()
|
||||
b.Fatal(err)
|
||||
case server := <-accepted:
|
||||
setSocketOptions(server)
|
||||
setSocketOptions(client)
|
||||
return server, client
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func setBenchmarkTCPDeadline(b *testing.B, conns ...*net.TCPConn) {
|
||||
b.Helper()
|
||||
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for _, conn := range conns {
|
||||
if err := conn.SetDeadline(deadline); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func closeBenchmarkTCPConns(conns ...*net.TCPConn) {
|
||||
for _, conn := range conns {
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func disableBenchmarkLogs(b *testing.B) {
|
||||
b.Helper()
|
||||
|
||||
previousLogger := log.Logger
|
||||
log.Logger = zerolog.Nop()
|
||||
b.Cleanup(func() {
|
||||
log.Logger = previousLogger
|
||||
})
|
||||
}
|
||||
|
||||
type benchmarkTCPReadResult struct {
|
||||
n int64
|
||||
err error
|
||||
}
|
||||
|
||||
type benchmarkTCPReaderOnly struct {
|
||||
conn *net.TCPConn
|
||||
}
|
||||
|
||||
func (r benchmarkTCPReaderOnly) Read(p []byte) (int, error) {
|
||||
return r.conn.Read(p)
|
||||
}
|
||||
|
||||
type benchmarkTCPWriterOnly struct {
|
||||
conn *net.TCPConn
|
||||
}
|
||||
|
||||
func (w benchmarkTCPWriterOnly) Write(p []byte) (int, error) {
|
||||
return w.conn.Write(p)
|
||||
}
|
||||
|
||||
type benchmarkReader struct {
|
||||
chunk []byte
|
||||
remaining int64
|
||||
}
|
||||
|
||||
func newBenchmarkReader(size int64) *benchmarkReader {
|
||||
chunk := bytes.Repeat([]byte{0x5a}, 64*1024)
|
||||
return &benchmarkReader{
|
||||
chunk: chunk,
|
||||
remaining: size,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *benchmarkReader) Reset(size int64) {
|
||||
r.remaining = size
|
||||
}
|
||||
|
||||
func (r *benchmarkReader) Read(p []byte) (int, error) {
|
||||
if r.remaining == 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
n := len(p)
|
||||
if n > len(r.chunk) {
|
||||
n = len(r.chunk)
|
||||
}
|
||||
if int64(n) > r.remaining {
|
||||
n = int(r.remaining)
|
||||
}
|
||||
|
||||
copy(p, r.chunk[:n])
|
||||
r.remaining -= int64(n)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
type benchmarkWriter struct {
|
||||
written int64
|
||||
}
|
||||
|
||||
func (w *benchmarkWriter) Reset() {
|
||||
w.written = 0
|
||||
}
|
||||
|
||||
func (w *benchmarkWriter) Written() int64 {
|
||||
return w.written
|
||||
}
|
||||
|
||||
func (w *benchmarkWriter) Write(p []byte) (int, error) {
|
||||
w.written += int64(len(p))
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
type benchmarkConn struct {
|
||||
reader bytes.Reader
|
||||
writer benchmarkWriter
|
||||
addr net.Addr
|
||||
}
|
||||
|
||||
func newBenchmarkConn(addr net.Addr) *benchmarkConn {
|
||||
return &benchmarkConn{addr: addr}
|
||||
}
|
||||
|
||||
func (c *benchmarkConn) ResetReader(buf []byte) {
|
||||
c.reader.Reset(buf)
|
||||
}
|
||||
|
||||
func (c *benchmarkConn) ResetWriter() {
|
||||
c.writer.Reset()
|
||||
}
|
||||
|
||||
func (c *benchmarkConn) Written() int64 {
|
||||
return c.writer.Written()
|
||||
}
|
||||
|
||||
func (c *benchmarkConn) Read(p []byte) (int, error) {
|
||||
return c.reader.Read(p)
|
||||
}
|
||||
|
||||
func (c *benchmarkConn) Write(p []byte) (int, error) {
|
||||
return c.writer.Write(p)
|
||||
}
|
||||
|
||||
func (c *benchmarkConn) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *benchmarkConn) LocalAddr() net.Addr {
|
||||
return c.addr
|
||||
}
|
||||
|
||||
func (c *benchmarkConn) RemoteAddr() net.Addr {
|
||||
return c.addr
|
||||
}
|
||||
|
||||
func (c *benchmarkConn) SetDeadline(time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *benchmarkConn) SetReadDeadline(time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *benchmarkConn) SetWriteDeadline(time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type benchmarkAddr string
|
||||
|
||||
func (a benchmarkAddr) Network() string {
|
||||
return "tcp"
|
||||
}
|
||||
|
||||
func (a benchmarkAddr) String() string {
|
||||
return string(a)
|
||||
}
|
||||
209
cmd/gateway/relay_test.go
Normal file
209
cmd/gateway/relay_test.go
Normal file
@@ -0,0 +1,209 @@
|
||||
// cmd/gateway/relay_test.go 包含用于约束 relay 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWriteAll(t *testing.T) {
|
||||
t.Run("partial writes", func(t *testing.T) {
|
||||
writer := &partialGatewayWriter{chunkSize: 2}
|
||||
if err := writeAll(writer, []byte("abcdef")); err != nil {
|
||||
t.Fatalf("writeAll() error = %v", err)
|
||||
}
|
||||
if got := writer.buf.String(); got != "abcdef" {
|
||||
t.Fatalf("written data = %q, want abcdef", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("short write", func(t *testing.T) {
|
||||
if err := writeAll(shortGatewayWriter{}, []byte("abcdef")); !errors.Is(err, io.ErrShortWrite) {
|
||||
t.Fatalf("writeAll() error = %v, want %v", err, io.ErrShortWrite)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("writer error", func(t *testing.T) {
|
||||
wantErr := errors.New("write failed")
|
||||
writer := &partialGatewayWriter{chunkSize: 2, err: wantErr}
|
||||
if err := writeAll(writer, []byte("abcdef")); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("writeAll() error = %v, want %v", err, wantErr)
|
||||
}
|
||||
if got := writer.buf.String(); got != "ab" {
|
||||
t.Fatalf("written data = %q, want ab", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCopyForwardCopiesData(t *testing.T) {
|
||||
var dst bytes.Buffer
|
||||
n, err := copyForward(&dst, strings.NewReader("payload"))
|
||||
if err != nil {
|
||||
t.Fatalf("copyForward() error = %v", err)
|
||||
}
|
||||
if n != int64(len("payload")) {
|
||||
t.Fatalf("copyForward() n = %d, want %d", n, len("payload"))
|
||||
}
|
||||
if got := dst.String(); got != "payload" {
|
||||
t.Fatalf("copyForward() data = %q, want payload", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyForwardWithPlainReaderWriter(t *testing.T) {
|
||||
dst := &plainGatewayWriter{}
|
||||
n, err := copyForward(dst, &plainGatewayReader{data: []byte("plain")})
|
||||
if err != nil {
|
||||
t.Fatalf("copyForward() error = %v", err)
|
||||
}
|
||||
if n != int64(len("plain")) {
|
||||
t.Fatalf("copyForward() n = %d, want %d", n, len("plain"))
|
||||
}
|
||||
if got := dst.buf.String(); got != "plain" {
|
||||
t.Fatalf("copyForward() data = %q, want plain", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyCopyClosesSides(t *testing.T) {
|
||||
src := &closingGatewayReader{}
|
||||
dst := &closingGatewayWriter{}
|
||||
|
||||
proxyCopy(dst, src)
|
||||
|
||||
if !src.closeReadCalled {
|
||||
t.Fatal("CloseRead was not called")
|
||||
}
|
||||
if !dst.closeWriteCalled {
|
||||
t.Fatal("CloseWrite was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyCopyRecoversAndClosesSides(t *testing.T) {
|
||||
src := &panicGatewayReader{}
|
||||
dst := &closingGatewayWriter{}
|
||||
|
||||
proxyCopy(dst, src)
|
||||
|
||||
if !src.closeReadCalled {
|
||||
t.Fatal("CloseRead was not called after panic")
|
||||
}
|
||||
if !dst.closeWriteCalled {
|
||||
t.Fatal("CloseWrite was not called after panic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseWriteFallsBackToClose(t *testing.T) {
|
||||
closer := &gatewayCloser{}
|
||||
closeWrite(closer)
|
||||
if !closer.closed {
|
||||
t.Fatal("Close was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAndPutProxyBuffer(t *testing.T) {
|
||||
buf := getProxyBuffer()
|
||||
if len(buf) != proxyBufferSize {
|
||||
t.Fatalf("buffer len = %d, want %d", len(buf), proxyBufferSize)
|
||||
}
|
||||
if cap(buf) != proxyBufferSize {
|
||||
t.Fatalf("buffer cap = %d, want %d", cap(buf), proxyBufferSize)
|
||||
}
|
||||
|
||||
putProxyBuffer(buf[:1])
|
||||
putProxyBuffer(make([]byte, 1))
|
||||
}
|
||||
|
||||
type partialGatewayWriter struct {
|
||||
chunkSize int
|
||||
err error
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (w *partialGatewayWriter) Write(p []byte) (int, error) {
|
||||
if len(p) > w.chunkSize {
|
||||
p = p[:w.chunkSize]
|
||||
}
|
||||
n, _ := w.buf.Write(p)
|
||||
if w.err != nil {
|
||||
return n, w.err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
type shortGatewayWriter struct{}
|
||||
|
||||
func (shortGatewayWriter) Write([]byte) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
type plainGatewayReader struct {
|
||||
data []byte
|
||||
}
|
||||
|
||||
func (r *plainGatewayReader) Read(p []byte) (int, error) {
|
||||
if len(r.data) == 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := copy(p, r.data)
|
||||
r.data = r.data[n:]
|
||||
return n, nil
|
||||
}
|
||||
|
||||
type plainGatewayWriter struct {
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (w *plainGatewayWriter) Write(p []byte) (int, error) {
|
||||
return w.buf.Write(p)
|
||||
}
|
||||
|
||||
type closingGatewayReader struct {
|
||||
closeReadCalled bool
|
||||
}
|
||||
|
||||
func (r *closingGatewayReader) Read([]byte) (int, error) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
func (r *closingGatewayReader) CloseRead() error {
|
||||
r.closeReadCalled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
type panicGatewayReader struct {
|
||||
closeReadCalled bool
|
||||
}
|
||||
|
||||
func (r *panicGatewayReader) Read([]byte) (int, error) {
|
||||
panic("read panic")
|
||||
}
|
||||
|
||||
func (r *panicGatewayReader) CloseRead() error {
|
||||
r.closeReadCalled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
type closingGatewayWriter struct {
|
||||
closeWriteCalled bool
|
||||
}
|
||||
|
||||
func (w *closingGatewayWriter) Write(p []byte) (int, error) {
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (w *closingGatewayWriter) CloseWrite() error {
|
||||
w.closeWriteCalled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
type gatewayCloser struct {
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (c *gatewayCloser) Close() error {
|
||||
c.closed = true
|
||||
return nil
|
||||
}
|
||||
70
cmd/gateway/tcp.go
Normal file
70
cmd/gateway/tcp.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// cmd/gateway/tcp.go 在未与 Admin HTTP 共用端口时启动普通 TCP Minecraft 监听器。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func runTcp(wg *sync.WaitGroup) {
|
||||
if wg != nil {
|
||||
defer wg.Done()
|
||||
}
|
||||
|
||||
port := normalizedTCPPort()
|
||||
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).
|
||||
Int("port", port).
|
||||
Msg("Failed to listen on port")
|
||||
}
|
||||
defer listener.Close()
|
||||
log.Info().
|
||||
Int("port", port).
|
||||
Msg("Listening for TCP connections")
|
||||
|
||||
for {
|
||||
// 接受传入的连接
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Error accepting")
|
||||
continue
|
||||
}
|
||||
setSocketOptions(conn)
|
||||
// 处理连接;后续握手解析、插件过滤和路由解析都在 handleRequest 中完成。
|
||||
gatewayMetrics.TCPConnectionStarted()
|
||||
go handleRequest(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func upstreamTcp(host string) net.Conn {
|
||||
// TCP 是默认上游传输,路由值没有协议前缀时都会走这里。
|
||||
conn, err := tcpDialer.Dial("tcp", host)
|
||||
if err != nil {
|
||||
gatewayMetrics.UpstreamDialError()
|
||||
log.Err(err).Str("host", host).Msg("Error dialing upstream")
|
||||
return nil
|
||||
}
|
||||
setSocketOptions(conn)
|
||||
return conn
|
||||
|
||||
}
|
||||
|
||||
var tcpDialer = net.Dialer{
|
||||
// 上游拨号失败应尽快返回给客户端连接处理流程,避免连接协程长期堆积。
|
||||
Timeout: 3 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}
|
||||
|
||||
func setSocketOptions(conn net.Conn) {
|
||||
if tcpConn, ok := conn.(*net.TCPConn); ok {
|
||||
tcpConn.SetNoDelay(true) // 禁用 Nagle 算法,降低 Minecraft 交互延迟。
|
||||
tcpConn.SetKeepAlive(true)
|
||||
tcpConn.SetKeepAlivePeriod(30 * time.Second)
|
||||
}
|
||||
}
|
||||
61
cmd/gateway/tcp_test.go
Normal file
61
cmd/gateway/tcp_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// cmd/gateway/tcp_test.go 包含用于约束 tcp 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSetSocketOptions(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
defer client.Close()
|
||||
defer server.Close()
|
||||
|
||||
setSocketOptions(client)
|
||||
}
|
||||
|
||||
func TestUpstreamTcp(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("Listen() error = %v", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
accepted := make(chan net.Conn, 1)
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
accepted <- conn
|
||||
}()
|
||||
|
||||
conn := upstreamTcp(listener.Addr().String())
|
||||
if conn == nil {
|
||||
t.Fatal("upstreamTcp() = nil, want connection")
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
select {
|
||||
case serverConn := <-accepted:
|
||||
serverConn.Close()
|
||||
case err := <-errCh:
|
||||
t.Fatalf("Accept() error = %v", err)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for upstream TCP connection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpstreamTcpReturnsNilOnDialError(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
if got := upstreamTcp("not a tcp address"); got != nil {
|
||||
t.Fatalf("upstreamTcp() = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
103
cmd/gateway/tcp_web_port_reuse.go
Normal file
103
cmd/gateway/tcp_web_port_reuse.go
Normal file
@@ -0,0 +1,103 @@
|
||||
// cmd/gateway/tcp_web_port_reuse.go 启动共享 TCP/Admin 监听器,按连接首包自动区分 HTTP 流量和 Minecraft 流量。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/tursom/mc-gateway/internal/tcphttpmux"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTCPPort = 25565
|
||||
// 首包超时沿用 tcphttpmux 默认值,保持同端口分流逻辑的单一来源。
|
||||
tcpWebInitialPacketTimeout = tcphttpmux.DefaultInitialPacketTimeout
|
||||
)
|
||||
|
||||
func normalizedTCPPort() int {
|
||||
// 静态配置未指定端口时保持 Minecraft 默认端口。
|
||||
if config.Tcp.Port == 0 {
|
||||
return defaultTCPPort
|
||||
}
|
||||
return config.Tcp.Port
|
||||
}
|
||||
|
||||
func normalizedWebSocketPort() int {
|
||||
if config.WebSocket.Port == 0 {
|
||||
return defaultWebSocketPort
|
||||
}
|
||||
return config.WebSocket.Port
|
||||
}
|
||||
|
||||
func normalizedWebSocketPath() string {
|
||||
// WebSocket 路径为空时回退到根路径,避免生成空的 HTTP 路由。
|
||||
if config.WebSocket.Path == "" {
|
||||
return "/"
|
||||
}
|
||||
return config.WebSocket.Path
|
||||
}
|
||||
|
||||
func tcpWebPortReuseEnabled() bool {
|
||||
// 是否共用端口完全由启用状态和端口相等推导,不引入额外配置开关。
|
||||
return config.Tcp.Enable &&
|
||||
config.WebSocket.Enable &&
|
||||
normalizedTCPPort() == normalizedWebSocketPort()
|
||||
}
|
||||
|
||||
func runTcpWebPortReuse(wg *sync.WaitGroup) {
|
||||
if wg != nil {
|
||||
defer wg.Done()
|
||||
}
|
||||
|
||||
port := normalizedTCPPort()
|
||||
// 同一个 listener 同时承载 Minecraft TCP 和 Admin HTTP,由 serveTcpWebPortReuse 分流。
|
||||
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).
|
||||
Int("port", port).
|
||||
Msg("Failed to listen on shared TCP/WebSocket port")
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Int("port", port).
|
||||
Str("admin_path", adminStartup.AdminPath).
|
||||
Msg("Listening for shared TCP and Admin connections")
|
||||
|
||||
if err := serveTcpWebPortReuse(listener, newGatewayHTTPHandler(), handleRequest); err != nil && !errors.Is(err, net.ErrClosed) {
|
||||
log.Fatal().Err(err).
|
||||
Int("port", port).
|
||||
Msg("Shared TCP/Admin server stopped")
|
||||
}
|
||||
}
|
||||
|
||||
func serveTcpWebPortReuse(listener net.Listener, handler http.Handler, tcpHandler func(net.Conn)) error {
|
||||
// tcphttpmux 只负责协议分流;指标、socket 选项和日志通过回调接回主包。
|
||||
return tcphttpmux.Serve(listener, handler, tcpHandler, tcphttpmux.Options{
|
||||
InitialPacketTimeout: tcpWebInitialPacketTimeout,
|
||||
SetSocketOptions: setSocketOptions,
|
||||
OnTCPConnection: gatewayMetrics.TCPConnectionStarted,
|
||||
OnAcceptError: func(err error) {
|
||||
log.Err(err).Msg("Error accepting shared TCP/WebSocket connection")
|
||||
},
|
||||
OnInitialPacketError: func(conn net.Conn, err error) {
|
||||
log.Debug().Err(err).
|
||||
Str("client", conn.RemoteAddr().String()).
|
||||
Msg("failed to read initial packet")
|
||||
},
|
||||
OnEmptyInitialPacket: func(conn net.Conn) {
|
||||
log.Debug().
|
||||
Str("client", conn.RemoteAddr().String()).
|
||||
Msg("initial packet is empty")
|
||||
},
|
||||
OnHTTPDeliveryFailed: func(conn net.Conn) {
|
||||
log.Debug().
|
||||
Str("client", conn.RemoteAddr().String()).
|
||||
Msg("failed to deliver HTTP connection")
|
||||
},
|
||||
})
|
||||
}
|
||||
218
cmd/gateway/tcp_web_port_reuse_test.go
Normal file
218
cmd/gateway/tcp_web_port_reuse_test.go
Normal file
@@ -0,0 +1,218 @@
|
||||
// cmd/gateway/tcp_web_port_reuse_test.go 包含用于约束 tcp web port reuse 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/tursom/mc-gateway/internal/gatewayconfig"
|
||||
"github.com/tursom/mc-gateway/protocol"
|
||||
)
|
||||
|
||||
func TestTCPWebPortReuseEnabledUsesNormalizedPorts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tcp gatewayconfig.ProtocolConfig
|
||||
websocket gatewayconfig.WebSocketConfig
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "explicit same port",
|
||||
tcp: gatewayconfig.ProtocolConfig{Enable: true, Port: 25565},
|
||||
websocket: gatewayconfig.WebSocketConfig{Enable: true, Port: 25565},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "websocket default port",
|
||||
tcp: gatewayconfig.ProtocolConfig{Enable: true, Port: defaultWebSocketPort},
|
||||
websocket: gatewayconfig.WebSocketConfig{Enable: true},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "tcp default port",
|
||||
tcp: gatewayconfig.ProtocolConfig{Enable: true},
|
||||
websocket: gatewayconfig.WebSocketConfig{Enable: true, Port: defaultTCPPort},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "different ports",
|
||||
tcp: gatewayconfig.ProtocolConfig{Enable: true, Port: 25565},
|
||||
websocket: gatewayconfig.WebSocketConfig{Enable: true, Port: 25566},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "tcp disabled",
|
||||
tcp: gatewayconfig.ProtocolConfig{Enable: false, Port: 25565},
|
||||
websocket: gatewayconfig.WebSocketConfig{Enable: true, Port: 25565},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "websocket disabled",
|
||||
tcp: gatewayconfig.ProtocolConfig{Enable: true, Port: 25565},
|
||||
websocket: gatewayconfig.WebSocketConfig{Enable: false, Port: 25565},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
config.Tcp = tt.tcp
|
||||
config.WebSocket = tt.websocket
|
||||
|
||||
if got := tcpWebPortReuseEnabled(); got != tt.want {
|
||||
t.Fatalf("tcpWebPortReuseEnabled() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeTcpWebPortReuseServesHTTPOnSharedPort(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
requested := make(chan string, 1)
|
||||
addr, stop := startPortReuseTestServer(
|
||||
t,
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requested <- r.URL.Path
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}),
|
||||
func(conn net.Conn) {
|
||||
conn.Close()
|
||||
},
|
||||
)
|
||||
defer stop()
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 2 * time.Second,
|
||||
Transport: &http.Transport{Proxy: nil},
|
||||
}
|
||||
resp, err := client.Get("http://" + addr + "/status")
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error = %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("HTTP status = %d, want %d", resp.StatusCode, http.StatusNoContent)
|
||||
}
|
||||
select {
|
||||
case got := <-requested:
|
||||
if got != "/status" {
|
||||
t.Fatalf("request path = %q, want /status", got)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for HTTP request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeTcpWebPortReuseUpgradesWebSocketOnSharedPort(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
config.WebSocket.Path = "/gateway"
|
||||
webSocketHandler := newWebSocketHandler()
|
||||
handlerDone := make(chan struct{})
|
||||
addr, stop := startPortReuseTestServer(
|
||||
t,
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer close(handlerDone)
|
||||
webSocketHandler.ServeHTTP(w, r)
|
||||
}),
|
||||
func(conn net.Conn) {
|
||||
conn.Close()
|
||||
},
|
||||
)
|
||||
defer stop()
|
||||
|
||||
conn, _, err := websocket.DefaultDialer.Dial("ws://"+addr+"/gateway", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Dial() error = %v", err)
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
select {
|
||||
case <-handlerDone:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for WebSocket handler")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeTcpWebPortReusePreservesMinecraftInitialPacket(t *testing.T) {
|
||||
defer saveGatewayState(t)()
|
||||
|
||||
hostCh := make(chan string, 1)
|
||||
addr, stop := startPortReuseTestServer(
|
||||
t,
|
||||
http.NotFoundHandler(),
|
||||
func(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
|
||||
buf := getProxyBuffer()
|
||||
defer putProxyBuffer(buf)
|
||||
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
hostCh <- ""
|
||||
return
|
||||
}
|
||||
hostCh <- protocol.GetMcHost(buf[:n])
|
||||
},
|
||||
)
|
||||
defer stop()
|
||||
|
||||
conn, err := net.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
t.Fatalf("Dial() error = %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if _, err := conn.Write(gatewayTestPacket("play.example")); err != nil {
|
||||
t.Fatalf("Write() error = %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case got := <-hostCh:
|
||||
if got != "play.example" {
|
||||
t.Fatalf("Minecraft host = %q, want play.example", got)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for Minecraft branch")
|
||||
}
|
||||
}
|
||||
|
||||
func startPortReuseTestServer(t *testing.T, handler http.Handler, tcpHandler func(net.Conn)) (string, func()) {
|
||||
t.Helper()
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("Listen() error = %v", err)
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- serveTcpWebPortReuse(listener, handler, tcpHandler)
|
||||
}()
|
||||
|
||||
stop := func() {
|
||||
if err := listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
|
||||
t.Fatalf("Close(listener) error = %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil && !errors.Is(err, net.ErrClosed) {
|
||||
t.Fatalf("serveTcpWebPortReuse() error = %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for port reuse server shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
return listener.Addr().String(), stop
|
||||
}
|
||||
209
cmd/gateway/test_helpers_test.go
Normal file
209
cmd/gateway/test_helpers_test.go
Normal file
@@ -0,0 +1,209 @@
|
||||
// cmd/gateway/test_helpers_test.go 包含用于约束 test helpers 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/tursom/mc-gateway/internal/adminconfig"
|
||||
"github.com/tursom/mc-gateway/internal/adminsession"
|
||||
"github.com/tursom/mc-gateway/internal/gatewayconfig"
|
||||
"github.com/tursom/mc-gateway/internal/gatewaymetrics"
|
||||
"github.com/tursom/mc-gateway/plugin/api"
|
||||
)
|
||||
|
||||
func saveGatewayState(t *testing.T) func() {
|
||||
t.Helper()
|
||||
|
||||
oldConfig := config
|
||||
oldCurrentPidFile := currentPidFile
|
||||
oldCurrentLogFile := currentLogFile
|
||||
oldLogger := log.Logger
|
||||
oldAdminStartup := adminStartup
|
||||
oldAdminDB := adminDB
|
||||
oldAdminDBPath := adminDBPath
|
||||
oldPluginsManager := pluginsManager
|
||||
oldAdminSessionManager := adminSessionManager
|
||||
oldRouteSnapshot := routeSnapshot.Clone()
|
||||
oldGatewayMetrics := gatewayMetrics
|
||||
|
||||
pluginLock.Lock()
|
||||
oldPlugins := plugins
|
||||
oldHooks := hooks
|
||||
plugins = make(map[string]api.Plugin)
|
||||
hooks = make(map[string]map[string]any)
|
||||
pluginLock.Unlock()
|
||||
|
||||
config = gatewayconfig.Config{}
|
||||
currentPidFile = ""
|
||||
currentLogFile = ""
|
||||
adminStartup = adminconfig.Config{
|
||||
DBPath: defaultAdminDBPath,
|
||||
TCPAdminPort: defaultTCPPort,
|
||||
AdminPath: defaultAdminPath,
|
||||
AdminAPIPrefix: defaultAdminAPIPrefix,
|
||||
SessionTTL: defaultAdminSessionTTL,
|
||||
}
|
||||
adminDB = nil
|
||||
adminDBPath = ""
|
||||
pluginsManager = nil
|
||||
adminSessionManager = adminsession.NewManager()
|
||||
publishRouteSnapshot(nil)
|
||||
gatewayMetrics = gatewaymetrics.New()
|
||||
log.Logger = zerolog.New(io.Discard)
|
||||
|
||||
return func() {
|
||||
if adminDB != nil && adminDB != oldAdminDB {
|
||||
_ = adminDB.Close()
|
||||
}
|
||||
if currentPidFile != "" && currentPidFile != oldCurrentPidFile {
|
||||
_ = os.Remove(currentPidFile)
|
||||
}
|
||||
|
||||
config = oldConfig
|
||||
currentPidFile = oldCurrentPidFile
|
||||
currentLogFile = oldCurrentLogFile
|
||||
adminStartup = oldAdminStartup
|
||||
adminDB = oldAdminDB
|
||||
adminDBPath = oldAdminDBPath
|
||||
pluginsManager = oldPluginsManager
|
||||
adminSessionManager = oldAdminSessionManager
|
||||
publishRouteSnapshot(oldRouteSnapshot)
|
||||
gatewayMetrics = oldGatewayMetrics
|
||||
log.Logger = oldLogger
|
||||
|
||||
pluginLock.Lock()
|
||||
plugins = oldPlugins
|
||||
hooks = oldHooks
|
||||
pluginLock.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func setGatewayTestRoutes(routes map[string]string) {
|
||||
publishRouteSnapshot(routes)
|
||||
}
|
||||
|
||||
func gatewayTestPacket(host string, tail ...byte) []byte {
|
||||
protocolVersion := byte(0x63)
|
||||
nextState := byte(0x02)
|
||||
extra := []byte(nil)
|
||||
if len(tail) > 0 {
|
||||
protocolVersion = tail[0]
|
||||
}
|
||||
if len(tail) > 1 {
|
||||
nextState = tail[1]
|
||||
}
|
||||
if len(tail) > 2 {
|
||||
extra = tail[2:]
|
||||
}
|
||||
payload := []byte{0x00, protocolVersion, byte(len(host))}
|
||||
payload = append(payload, host...)
|
||||
payload = append(payload, 0x63, 0xdd, nextState)
|
||||
payload = append(payload, extra...)
|
||||
packet := []byte{byte(len(payload))}
|
||||
return append(packet, payload...)
|
||||
}
|
||||
|
||||
type gatewayTestConn struct {
|
||||
mu sync.Mutex
|
||||
readBuf []byte
|
||||
readErr error
|
||||
writeBuf bytes.Buffer
|
||||
writeErr error
|
||||
|
||||
closed bool
|
||||
local net.Addr
|
||||
remote net.Addr
|
||||
}
|
||||
|
||||
func newGatewayTestConn(readBuf []byte) *gatewayTestConn {
|
||||
return &gatewayTestConn{
|
||||
readBuf: append([]byte(nil), readBuf...),
|
||||
local: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 25565},
|
||||
remote: &net.TCPAddr{IP: net.ParseIP("127.0.0.2"), Port: 45678},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *gatewayTestConn) Read(p []byte) (int, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.readErr != nil {
|
||||
return 0, c.readErr
|
||||
}
|
||||
if len(c.readBuf) == 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := copy(p, c.readBuf)
|
||||
c.readBuf = c.readBuf[n:]
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (c *gatewayTestConn) Write(p []byte) (int, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.writeErr != nil {
|
||||
return 0, c.writeErr
|
||||
}
|
||||
return c.writeBuf.Write(p)
|
||||
}
|
||||
|
||||
func (c *gatewayTestConn) Close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *gatewayTestConn) isClosed() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
return c.closed
|
||||
}
|
||||
|
||||
func (c *gatewayTestConn) LocalAddr() net.Addr {
|
||||
return c.local
|
||||
}
|
||||
|
||||
func (c *gatewayTestConn) RemoteAddr() net.Addr {
|
||||
return c.remote
|
||||
}
|
||||
|
||||
func (c *gatewayTestConn) SetDeadline(time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *gatewayTestConn) SetReadDeadline(time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *gatewayTestConn) SetWriteDeadline(time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func registerGatewayUpstreamHook(
|
||||
t *testing.T,
|
||||
acceptor func(net.Conn, string) bool,
|
||||
handler func(net.Conn, string) (net.Conn, error),
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
pluginLock.Lock()
|
||||
hooks["test-plugin"] = make(map[string]any)
|
||||
pluginLock.Unlock()
|
||||
|
||||
if err := api.RegisterHookHandler(&Gateway{pluginId: "test-plugin"}, api.HookUpstream, acceptor, handler); err != nil {
|
||||
t.Fatalf("RegisterHookHandler() error = %v", err)
|
||||
}
|
||||
}
|
||||
124
cmd/gateway/websocket.go
Normal file
124
cmd/gateway/websocket.go
Normal file
@@ -0,0 +1,124 @@
|
||||
// cmd/gateway/websocket.go 把 WebSocket 会话适配为 net.Conn,让浏览器客户端复用网关请求路径。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
// 当前网关把 WebSocket 当作传输层入口,先允许所有来源;生产暴露时应在反向代理层收紧来源。
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
type (
|
||||
// WebSocket 连接适配器,实现 net.Conn 接口
|
||||
webSocketConn struct {
|
||||
*websocket.Conn
|
||||
reader io.Reader
|
||||
}
|
||||
)
|
||||
|
||||
// WebSocket 处理函数
|
||||
func handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
// 升级 HTTP 连接为 WebSocket
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to upgrade WebSocket connection")
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
gatewayMetrics.WebSocketConnectionStarted()
|
||||
// WebSocket 连接包装为 net.Conn 后进入同一个 handleRequest,复用插件、路由和转发逻辑。
|
||||
handleRequest(&webSocketConn{Conn: conn})
|
||||
}
|
||||
|
||||
func (w *webSocketConn) Read(b []byte) (n int, err error) {
|
||||
for {
|
||||
if w.reader != nil {
|
||||
// 当前消息帧没读完前持续从同一个 reader 读取,模拟流式 net.Conn。
|
||||
n, err = w.reader.Read(b)
|
||||
if errors.Is(err, io.EOF) {
|
||||
w.reader = nil
|
||||
if n > 0 {
|
||||
return n, nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
messageType, reader, err := w.NextReader()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if messageType != websocket.BinaryMessage && messageType != websocket.TextMessage {
|
||||
// 控制帧不进入 Minecraft 协议流。
|
||||
continue
|
||||
}
|
||||
w.reader = reader
|
||||
}
|
||||
}
|
||||
|
||||
func (w *webSocketConn) Write(b []byte) (n int, err error) {
|
||||
// 每次 Write 输出一个二进制 WebSocket 消息,保持与 Minecraft packet 边界无关的字节流语义。
|
||||
writer, err := w.NextWriter(websocket.BinaryMessage)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
n, err = writer.Write(b)
|
||||
closeErr := writer.Close()
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
if closeErr != nil {
|
||||
return n, closeErr
|
||||
}
|
||||
if n != len(b) {
|
||||
return n, io.ErrShortWrite
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (w *webSocketConn) SetDeadline(t time.Time) error {
|
||||
if err := w.SetReadDeadline(t); err != nil {
|
||||
return err
|
||||
}
|
||||
return w.SetWriteDeadline(t)
|
||||
}
|
||||
|
||||
func newWebSocketHandler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
// 路径来自运行态服务配置,允许管理端把 WebSocket 入口挂到子路径。
|
||||
mux.HandleFunc(normalizedWebSocketPath(), handleWebSocket)
|
||||
return mux
|
||||
}
|
||||
|
||||
// 启动 WebSocket 服务器
|
||||
func runWebSocket(wg *sync.WaitGroup) {
|
||||
if wg != nil {
|
||||
defer wg.Done()
|
||||
}
|
||||
|
||||
port := normalizedWebSocketPort()
|
||||
path := normalizedWebSocketPath()
|
||||
|
||||
log.Info().Int("port", port).Str("path", path).Msg("Starting WebSocket server")
|
||||
server := &http.Server{Addr: fmt.Sprintf(":%d", port), Handler: newWebSocketHandler()}
|
||||
if err := server.ListenAndServe(); err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to start WebSocket server")
|
||||
}
|
||||
}
|
||||
137
cmd/gateway/websocket_test.go
Normal file
137
cmd/gateway/websocket_test.go
Normal file
@@ -0,0 +1,137 @@
|
||||
// cmd/gateway/websocket_test.go 包含用于约束 websocket 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func TestWebSocketConnReadWriteAndDeadline(t *testing.T) {
|
||||
serverConn, clientConn, cleanup := newGatewayWebSocketPair(t)
|
||||
defer cleanup()
|
||||
|
||||
conn := &webSocketConn{Conn: serverConn}
|
||||
|
||||
if err := clientConn.WriteMessage(websocket.TextMessage, []byte("hello")); err != nil {
|
||||
t.Fatalf("client WriteMessage() error = %v", err)
|
||||
}
|
||||
|
||||
buf := make([]byte, len("hello"))
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("Read() error = %v", err)
|
||||
}
|
||||
if got := string(buf[:n]); got != "hello" {
|
||||
t.Fatalf("Read() = %q, want hello", got)
|
||||
}
|
||||
|
||||
if err := conn.SetDeadline(time.Now().Add(time.Second)); err != nil {
|
||||
t.Fatalf("SetDeadline() error = %v", err)
|
||||
}
|
||||
|
||||
payload := []byte("response")
|
||||
n, err = conn.Write(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("Write() error = %v", err)
|
||||
}
|
||||
if n != len(payload) {
|
||||
t.Fatalf("Write() n = %d, want %d", n, len(payload))
|
||||
}
|
||||
|
||||
messageType, got, err := clientConn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("client ReadMessage() error = %v", err)
|
||||
}
|
||||
if messageType != websocket.BinaryMessage {
|
||||
t.Fatalf("message type = %d, want %d", messageType, websocket.BinaryMessage)
|
||||
}
|
||||
if string(got) != string(payload) {
|
||||
t.Fatalf("message payload = %q, want %q", got, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSocketConnReadContinuesAcrossFrames(t *testing.T) {
|
||||
serverConn, clientConn, cleanup := newGatewayWebSocketPair(t)
|
||||
defer cleanup()
|
||||
|
||||
conn := &webSocketConn{Conn: serverConn}
|
||||
|
||||
if err := clientConn.WriteMessage(websocket.BinaryMessage, []byte("abc")); err != nil {
|
||||
t.Fatalf("client WriteMessage(first) error = %v", err)
|
||||
}
|
||||
if err := clientConn.WriteMessage(websocket.BinaryMessage, []byte("def")); err != nil {
|
||||
t.Fatalf("client WriteMessage(second) error = %v", err)
|
||||
}
|
||||
|
||||
buf := make([]byte, 2)
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("Read(first) error = %v", err)
|
||||
}
|
||||
if got := string(buf[:n]); got != "ab" {
|
||||
t.Fatalf("Read(first) = %q, want ab", got)
|
||||
}
|
||||
n, err = conn.Read(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("Read(second) error = %v", err)
|
||||
}
|
||||
if got := string(buf[:n]); got != "c" {
|
||||
t.Fatalf("Read(second) = %q, want c", got)
|
||||
}
|
||||
n, err = conn.Read(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("Read(third) error = %v", err)
|
||||
}
|
||||
if got := string(buf[:n]); got != "de" {
|
||||
t.Fatalf("Read(third) = %q, want de", got)
|
||||
}
|
||||
}
|
||||
|
||||
func newGatewayWebSocketPair(t *testing.T) (*websocket.Conn, *websocket.Conn, func()) {
|
||||
t.Helper()
|
||||
|
||||
serverConnCh := make(chan *websocket.Conn, 1)
|
||||
errCh := make(chan error, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
serverConnCh <- conn
|
||||
}))
|
||||
|
||||
url := "ws" + strings.TrimPrefix(server.URL, "http")
|
||||
clientConn, _, err := websocket.DefaultDialer.Dial(url, nil)
|
||||
if err != nil {
|
||||
server.Close()
|
||||
t.Fatalf("Dial() error = %v", err)
|
||||
}
|
||||
|
||||
var serverConn *websocket.Conn
|
||||
select {
|
||||
case serverConn = <-serverConnCh:
|
||||
case err := <-errCh:
|
||||
clientConn.Close()
|
||||
server.Close()
|
||||
t.Fatalf("Upgrade() error = %v", err)
|
||||
case <-time.After(2 * time.Second):
|
||||
clientConn.Close()
|
||||
server.Close()
|
||||
t.Fatal("timed out waiting for websocket upgrade")
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
_ = clientConn.Close()
|
||||
_ = serverConn.Close()
|
||||
server.Close()
|
||||
}
|
||||
|
||||
return serverConn, clientConn, cleanup
|
||||
}
|
||||
125
cmd/kcp/main.go
Normal file
125
cmd/kcp/main.go
Normal file
@@ -0,0 +1,125 @@
|
||||
// cmd/kcp/main.go 提供独立的 KCP 到 TCP 代理工具,用于测试或演示 KCP 传输行为。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/tursom/mc-gateway/protocol"
|
||||
"github.com/xtaci/kcp-go"
|
||||
)
|
||||
|
||||
var (
|
||||
localPort = 25565
|
||||
|
||||
mcHost = "bh.mc.tursom.cn"
|
||||
mcPort = 25566
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 {
|
||||
mcHost = os.Args[1]
|
||||
}
|
||||
|
||||
// 监听TCP端口
|
||||
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", localPort))
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).
|
||||
Int("port", localPort).
|
||||
Msg("Failed to listen on port")
|
||||
}
|
||||
defer listener.Close()
|
||||
log.Info().
|
||||
Int("port", localPort).
|
||||
Msg("Listening for TCP connections")
|
||||
|
||||
for {
|
||||
// 接受传入的连接
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Error accepting")
|
||||
continue
|
||||
}
|
||||
log.Info().
|
||||
Str("client", conn.RemoteAddr().String()).
|
||||
Int("port", localPort).
|
||||
Msg("Accepted connection")
|
||||
// 处理连接
|
||||
go handlerConn(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func handlerConn(client net.Conn) {
|
||||
defer client.Close()
|
||||
setSocketOptions(client)
|
||||
|
||||
conn, err := kcp.DialWithOptions(fmt.Sprintf("%s:%d", mcHost, mcPort), nil, 10, 5)
|
||||
if err != nil {
|
||||
log.Error().Err(err).
|
||||
Msg("Failed to dial KCP server")
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
conn.SetACKNoDelay(true)
|
||||
|
||||
buf := make([]byte, 1024)
|
||||
n, err := client.Read(buf)
|
||||
if err != nil {
|
||||
log.Err(err).
|
||||
Str("client", client.RemoteAddr().String()).
|
||||
Msg("Error reading hostname")
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
log.Err(errors.New("empty buffer")).
|
||||
Str("client", client.RemoteAddr().String()).
|
||||
Msg("Error: buffer is empty")
|
||||
return
|
||||
}
|
||||
buf = buf[:n]
|
||||
|
||||
buf = protocol.ReplaceMcHost(buf, mcHost)
|
||||
_, err = conn.Write(buf) // 写入数据到 QUIC 流
|
||||
if err != nil {
|
||||
log.Err(err).
|
||||
Str("client", client.RemoteAddr().String()).
|
||||
Str("host", mcHost).
|
||||
Int("port", mcPort).
|
||||
Msg("Error writing to QUIC stream")
|
||||
return
|
||||
}
|
||||
// 释放 buf
|
||||
buf = nil
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go copyData(conn, client, &wg)
|
||||
copyData(client, conn, nil)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func copyData(src io.Reader, dst io.Writer, wg *sync.WaitGroup) {
|
||||
if wg != nil {
|
||||
defer wg.Done()
|
||||
}
|
||||
|
||||
_, err := io.Copy(dst, src)
|
||||
if err != nil && err != io.EOF {
|
||||
log.Err(err).Msg("Error copying data")
|
||||
}
|
||||
}
|
||||
|
||||
func setSocketOptions(conn net.Conn) {
|
||||
if tcpConn, ok := conn.(*net.TCPConn); ok {
|
||||
tcpConn.SetNoDelay(true) // 禁用 Nagle 算法
|
||||
tcpConn.SetKeepAlive(true)
|
||||
tcpConn.SetKeepAlivePeriod(30 * time.Second)
|
||||
}
|
||||
}
|
||||
96
cmd/kcp/main_test.go
Normal file
96
cmd/kcp/main_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
// cmd/kcp/main_test.go 包含用于约束 kcp 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCopyData(t *testing.T) {
|
||||
var dst bytes.Buffer
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
|
||||
copyData(strings.NewReader("payload"), &dst, &wg)
|
||||
wg.Wait()
|
||||
|
||||
if got := dst.String(); got != "payload" {
|
||||
t.Fatalf("copyData() wrote %q, want payload", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyDataIgnoresEOFAndLogsOtherErrors(t *testing.T) {
|
||||
copyData(strings.NewReader(""), io.Discard, nil)
|
||||
copyData(errorReader{err: errors.New("read failed")}, io.Discard, nil)
|
||||
}
|
||||
|
||||
func TestSetSocketOptions(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
defer client.Close()
|
||||
defer server.Close()
|
||||
|
||||
setSocketOptions(client)
|
||||
}
|
||||
|
||||
func TestSetSocketOptionsTCPConn(t *testing.T) {
|
||||
client, server := newKcpTestTCPConnPair(t)
|
||||
defer client.Close()
|
||||
defer server.Close()
|
||||
|
||||
setSocketOptions(client)
|
||||
setSocketOptions(server)
|
||||
}
|
||||
|
||||
type errorReader struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (r errorReader) Read([]byte) (int, error) {
|
||||
return 0, r.err
|
||||
}
|
||||
|
||||
func newKcpTestTCPConnPair(t *testing.T) (*net.TCPConn, *net.TCPConn) {
|
||||
t.Helper()
|
||||
|
||||
listener, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.ParseIP("127.0.0.1")})
|
||||
if err != nil {
|
||||
t.Fatalf("ListenTCP() error = %v", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
accepted := make(chan *net.TCPConn, 1)
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
conn, err := listener.AcceptTCP()
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
accepted <- conn
|
||||
}()
|
||||
|
||||
client, err := net.DialTCP("tcp", nil, listener.Addr().(*net.TCPAddr))
|
||||
if err != nil {
|
||||
t.Fatalf("DialTCP() error = %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case server := <-accepted:
|
||||
return client, server
|
||||
case err := <-errCh:
|
||||
client.Close()
|
||||
t.Fatalf("AcceptTCP() error = %v", err)
|
||||
case <-time.After(2 * time.Second):
|
||||
client.Close()
|
||||
t.Fatal("timed out waiting for TCP accept")
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
144
cmd/quic/main.go
Normal file
144
cmd/quic/main.go
Normal file
@@ -0,0 +1,144 @@
|
||||
// cmd/quic/main.go 提供独立的 QUIC 到 TCP 代理工具,用于测试或演示 QUIC 传输行为。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/quic-go/quic-go"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/tursom/mc-gateway/protocol"
|
||||
)
|
||||
|
||||
var (
|
||||
localPort = 25565
|
||||
|
||||
mcHost = "bh.mc.tursom.cn"
|
||||
mcPort = 25565
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 {
|
||||
mcHost = os.Args[1]
|
||||
}
|
||||
|
||||
// 监听TCP端口
|
||||
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", localPort))
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).
|
||||
Int("port", localPort).
|
||||
Msg("Failed to listen on port")
|
||||
}
|
||||
defer listener.Close()
|
||||
log.Info().
|
||||
Int("port", localPort).
|
||||
Msg("Listening for TCP connections")
|
||||
|
||||
for {
|
||||
// 接受传入的连接
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Error accepting")
|
||||
continue
|
||||
}
|
||||
log.Info().
|
||||
Str("client", conn.RemoteAddr().String()).
|
||||
Int("port", localPort).
|
||||
Msg("Accepted connection")
|
||||
// 处理连接
|
||||
go handlerConn(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func handlerConn(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
|
||||
setSocketOptions(conn)
|
||||
|
||||
tlsConf := &tls.Config{
|
||||
InsecureSkipVerify: true, // 跳过证书检查
|
||||
NextProtos: []string{"minecraft", "quic", "raw", "h3"},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) // 3s handshake timeout
|
||||
defer cancel()
|
||||
|
||||
quicConn, err := quic.DialAddr(ctx, fmt.Sprintf("%s:%d", mcHost, mcPort), tlsConf, nil)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to dial QUIC")
|
||||
return
|
||||
}
|
||||
|
||||
stream, err := quicConn.OpenStream()
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to open stream")
|
||||
return
|
||||
}
|
||||
defer stream.Close()
|
||||
log.Info().
|
||||
Msg("QUIC stream opened")
|
||||
|
||||
// 读写 QUIC 流数据。
|
||||
|
||||
buf := make([]byte, 1024)
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
log.Err(err).
|
||||
Str("client", conn.RemoteAddr().String()).
|
||||
Msg("Error reading hostname")
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
log.Err(errors.New("empty buffer")).
|
||||
Str("client", conn.RemoteAddr().String()).
|
||||
Msg("Error: buffer is empty")
|
||||
return
|
||||
}
|
||||
buf = buf[:n]
|
||||
|
||||
buf = protocol.ReplaceMcHost(buf, mcHost)
|
||||
_, err = stream.Write(buf) // 写入数据到 QUIC 流
|
||||
if err != nil {
|
||||
log.Err(err).
|
||||
Str("client", conn.RemoteAddr().String()).
|
||||
Str("host", mcHost).
|
||||
Int("port", mcPort).
|
||||
Msg("Error writing to QUIC stream")
|
||||
return
|
||||
}
|
||||
// 释放 buf
|
||||
buf = nil
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go copyData(stream, conn, &wg)
|
||||
copyData(conn, stream, nil)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func copyData(src io.Reader, dst io.Writer, wg *sync.WaitGroup) {
|
||||
if wg != nil {
|
||||
defer wg.Done()
|
||||
}
|
||||
|
||||
_, err := io.Copy(dst, src)
|
||||
if err != nil && err != io.EOF {
|
||||
log.Err(err).Msg("Error copying data")
|
||||
}
|
||||
}
|
||||
|
||||
func setSocketOptions(conn net.Conn) {
|
||||
if tcpConn, ok := conn.(*net.TCPConn); ok {
|
||||
tcpConn.SetNoDelay(true) // 禁用 Nagle 算法
|
||||
tcpConn.SetKeepAlive(true)
|
||||
tcpConn.SetKeepAlivePeriod(30 * time.Second)
|
||||
}
|
||||
}
|
||||
96
cmd/quic/main_test.go
Normal file
96
cmd/quic/main_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
// cmd/quic/main_test.go 包含用于约束 quic 行为的测试。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCopyData(t *testing.T) {
|
||||
var dst bytes.Buffer
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
|
||||
copyData(strings.NewReader("payload"), &dst, &wg)
|
||||
wg.Wait()
|
||||
|
||||
if got := dst.String(); got != "payload" {
|
||||
t.Fatalf("copyData() wrote %q, want payload", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyDataIgnoresEOFAndLogsOtherErrors(t *testing.T) {
|
||||
copyData(strings.NewReader(""), io.Discard, nil)
|
||||
copyData(errorReader{err: errors.New("read failed")}, io.Discard, nil)
|
||||
}
|
||||
|
||||
func TestSetSocketOptions(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
defer client.Close()
|
||||
defer server.Close()
|
||||
|
||||
setSocketOptions(client)
|
||||
}
|
||||
|
||||
func TestSetSocketOptionsTCPConn(t *testing.T) {
|
||||
client, server := newQuicTestTCPConnPair(t)
|
||||
defer client.Close()
|
||||
defer server.Close()
|
||||
|
||||
setSocketOptions(client)
|
||||
setSocketOptions(server)
|
||||
}
|
||||
|
||||
type errorReader struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (r errorReader) Read([]byte) (int, error) {
|
||||
return 0, r.err
|
||||
}
|
||||
|
||||
func newQuicTestTCPConnPair(t *testing.T) (*net.TCPConn, *net.TCPConn) {
|
||||
t.Helper()
|
||||
|
||||
listener, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.ParseIP("127.0.0.1")})
|
||||
if err != nil {
|
||||
t.Fatalf("ListenTCP() error = %v", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
accepted := make(chan *net.TCPConn, 1)
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
conn, err := listener.AcceptTCP()
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
accepted <- conn
|
||||
}()
|
||||
|
||||
client, err := net.DialTCP("tcp", nil, listener.Addr().(*net.TCPAddr))
|
||||
if err != nil {
|
||||
t.Fatalf("DialTCP() error = %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case server := <-accepted:
|
||||
return client, server
|
||||
case err := <-errCh:
|
||||
client.Close()
|
||||
t.Fatalf("AcceptTCP() error = %v", err)
|
||||
case <-time.After(2 * time.Second):
|
||||
client.Close()
|
||||
t.Fatal("timed out waiting for TCP accept")
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
7
compose.override.yaml
Normal file
7
compose.override.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
# compose.override.yaml 把本地 Compose 覆盖项与偏生产形态的基础服务定义分开维护。
|
||||
|
||||
services:
|
||||
mc-gateway:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
24
compose.yaml
Normal file
24
compose.yaml
Normal file
@@ -0,0 +1,24 @@
|
||||
# compose.yaml 定义偏生产形态的网关容器、数据卷、端口和避开代理干扰的健康检查。
|
||||
|
||||
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
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"port": 25565,
|
||||
"default": "mc1:25565",
|
||||
"hosts": {
|
||||
"dev": "mc1:25565",
|
||||
"dev.lan": "mc1:25568"
|
||||
}
|
||||
}
|
||||
27
config.example.toml
Normal file
27
config.example.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
# config.example.toml 说明启动监听器和运行态默认值所需的静态网关配置字段。
|
||||
|
||||
# pid 文件
|
||||
pid_file = "gateway.pid"
|
||||
|
||||
[log]
|
||||
level = "warn"
|
||||
file = "logs/mc1.log"
|
||||
|
||||
[tcp]
|
||||
enable = true
|
||||
port = 25565
|
||||
|
||||
[quic]
|
||||
enable = true
|
||||
port = 25565
|
||||
pid_file = ["minecraft", "quic", "raw", "h3"]
|
||||
|
||||
[kcp]
|
||||
enable = true
|
||||
port = 25566
|
||||
data_shards = 10
|
||||
parity_shards = 3
|
||||
|
||||
[hosts]
|
||||
dev = "mc1:25565"
|
||||
"dev.lan" = "mc1:25568"
|
||||
436
docs/admin-page-design.md
Normal file
436
docs/admin-page-design.md
Normal file
@@ -0,0 +1,436 @@
|
||||
# 管理页面设计
|
||||
|
||||
## 背景
|
||||
|
||||
当前 gateway 已经具备 TCP 与 HTTP/WebSocket 的端口复用能力:
|
||||
|
||||
- Minecraft 原始 TCP 连接进入 `handleRequest`,再按握手包里的 host 路由到上游。
|
||||
- WebSocket 连接先进入 HTTP handler,再 upgrade 为 WebSocket,最后同样复用 `handleRequest`。
|
||||
- `tcp_web_port_reuse.go` 已经提供首包识别、连接回放和 `http.Server.Serve(chanListener)` 这条分流路径。
|
||||
|
||||
新的管理方案不再以 `config.toml` 为中心。进程默认启动即可工作:
|
||||
|
||||
- 默认在 `25565` 端口启动 Minecraft TCP 监听。
|
||||
- 同一个 `25565` 端口同时承载后台管理页面和管理 API。
|
||||
- 用户、权限、路由、其他协议服务配置都持久化到 SQLite。
|
||||
- 其他服务和路由通过后台管理配置,不再依赖配置文件。
|
||||
|
||||
## 功能需求对齐
|
||||
|
||||
第一版管理页面按以下边界设计。
|
||||
|
||||
### 必须支持
|
||||
|
||||
- 进程启动不依赖配置文件;缺省状态下直接监听 `25565`。
|
||||
- TCP 转发和后台管理默认共用同一个 TCP listener。
|
||||
- 管理入口必须有认证保护。复用公网 Minecraft 端口时,不能出现无认证管理 API。
|
||||
- 管理页面内置用户和权限系统,角色分为管理员、成员和游客。
|
||||
- 所有持久化状态进入 SQLite,包括用户、路由、服务配置和审计日志。
|
||||
- 页面提供一个实际可用的后台界面,而不是只返回 JSON:
|
||||
- 概览:进程 PID、运行时长、SQLite 路径、TCP/Admin 端口、各协议服务状态。
|
||||
- 路由管理:展示、搜索、新增、修改、删除 SQLite 中的路由记录。
|
||||
- 服务管理:配置 KCP、QUIC、WebSocket 的启用状态、端口和协议参数。
|
||||
- 用户管理:管理员可以新增、禁用、修改角色和重置密码。
|
||||
- 连接统计:展示总连接数、当前活跃连接数、路由命中次数、上游连接失败次数。
|
||||
- 游客登录后可以查看当前 SQLite 路由记录,但不能修改路由、配置服务或查看用户列表。
|
||||
- 路由记录必须持久化到 SQLite,进程重启后仍然生效。
|
||||
- 服务配置必须持久化到 SQLite,进程重启后按后台配置恢复。
|
||||
- 所有写操作需要记录审计日志,至少包含操作者、来源 IP、操作类型、目标对象和结果。
|
||||
|
||||
### 暂不支持
|
||||
|
||||
- 不再支持 `config.toml` 作为启动配置、热加载配置或路由来源。
|
||||
- 不做多租户、组织空间和自定义细粒度权限。
|
||||
- 不做 HTTPS/TLS 终止;需要 HTTPS 时由外部反向代理或负载均衡器处理。
|
||||
- 不做 Minecraft 玩家在线列表、踢人、封禁等游戏服管理能力。
|
||||
- 不做插件管理、二进制升级、进程重启。
|
||||
- 不把每条连接的完整客户端地址、目标地址长期保存在内存里。
|
||||
|
||||
## 默认启动设计
|
||||
|
||||
无配置文件时使用以下默认值:
|
||||
|
||||
| 项 | 默认值 | 环境变量 |
|
||||
| --- | --- | --- |
|
||||
| SQLite 数据库 | `mc-gateway.sqlite3` | `MC_GATEWAY_DB` |
|
||||
| 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 | 默认禁用 | 后台配置 |
|
||||
| 路由表 | 默认空表 | 后台配置 |
|
||||
| 会话有效期 | 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`,也不能落在静态资源路径下,例如 `config.js` 或 `js/`。
|
||||
- `MC_GATEWAY_ADMIN_STATIC_DIR` 指向 Admin 前端构建产物目录;Docker 镜像内使用 `/usr/share/mc-gateway/admin_static`。
|
||||
- 环境变量覆盖的是本次进程的 Admin 入口;第一次创建 SQLite 默认服务配置时,应把解析后的 TCP/Admin 端口写入 `services.tcp_admin.port`。
|
||||
|
||||
启动流程:
|
||||
|
||||
1. 读取并校验启动期环境变量,得到 SQLite 路径、TCP/Admin 端口、Admin 页面路径、Admin API 前缀。
|
||||
2. 打开 SQLite 数据库;不存在时自动创建。
|
||||
3. 执行 schema migration。
|
||||
4. 确保默认服务配置存在:TCP/Admin listener 启用,端口为启动期解析后的端口。
|
||||
5. 如果用户表为空,进入首次初始化模式。
|
||||
6. 加载启用的路由记录为内存只读快照。
|
||||
7. 启动 TCP/Admin 共享 listener。
|
||||
8. 按 SQLite 中的服务配置启动 KCP、QUIC、WebSocket。
|
||||
|
||||
首次初始化:
|
||||
|
||||
- 当用户表为空时,启动期解析后的 Admin 页面路径显示初始化管理员页面。
|
||||
- 初始化接口只允许创建第一个管理员账号。
|
||||
- 第一个管理员创建成功后,初始化接口永久关闭。
|
||||
- 也可以通过环境变量 `MC_GATEWAY_ADMIN_PASSWORD` 配合默认用户名 `admin` 在启动时创建初始管理员;这不是配置文件,只是无交互部署入口。
|
||||
|
||||
SQLite 路径:
|
||||
|
||||
- 默认使用当前工作目录下的 `mc-gateway.sqlite3`。
|
||||
- 如需改路径,第一版只接受启动参数或环境变量,例如 `MC_GATEWAY_DB`;不引入配置文件。
|
||||
|
||||
## 运行模型
|
||||
|
||||
TCP/Admin listener 是基础入口:
|
||||
|
||||
```text
|
||||
net.Listen(:startup env port or db service setting)
|
||||
|
|
||||
v
|
||||
Accept
|
||||
|
|
||||
v
|
||||
读取首批字节
|
||||
|
|
||||
+-- HTTP/Admin/WebSocket
|
||||
| -> HTTP channel listener
|
||||
| -> http.Server.Serve
|
||||
|
|
||||
+-- Minecraft TCP
|
||||
-> 回放首批字节
|
||||
-> handleRequest
|
||||
-> mapToHost
|
||||
-> route snapshot
|
||||
-> proxyConnections
|
||||
```
|
||||
|
||||
核心规则:
|
||||
|
||||
- TCP/Admin listener 默认永远启用,避免后台管理入口丢失。
|
||||
- TCP/Admin 端口可以由启动期环境变量覆盖,也可以保存在 SQLite;最终监听端口以启动期解析结果优先。
|
||||
- TCP/Admin 端口修改后第一版按重启后生效处理。
|
||||
- Admin 页面路径和 API 前缀只由启动期环境变量控制,不通过后台页面修改,避免运行中替换管理入口导致当前会话失效。
|
||||
- KCP、QUIC、WebSocket 由后台管理配置启用状态和端口。
|
||||
- 如果某个服务的端口或协议参数无法热更新,页面必须标记为“重启后生效”或提供明确的重启服务操作。
|
||||
- 路由变更不需要重启,也不需要 reload;SQLite 写入成功后刷新路由快照即可影响新连接。
|
||||
|
||||
## HTTP 路由与页面
|
||||
|
||||
Admin 前端源码使用 TypeScript 拆分,构建为原生 ES modules。Go 不再 `embed` 前端文件,而是从 `MC_GATEWAY_ADMIN_STATIC_DIR` 指向的目录读取并透传静态响应。运行时 API 前缀通过动态 `config.js` 响应注入。
|
||||
|
||||
建议目录:
|
||||
|
||||
```text
|
||||
cmd/gateway/admin.go
|
||||
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
|
||||
js/
|
||||
```
|
||||
|
||||
API 路由:
|
||||
|
||||
| 方法 | 路径 | 权限 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/admin/` | 公开页面 | 管理页面入口或首次初始化页面 |
|
||||
| GET | `/admin/app.css` | 公开页面 | 页面样式 |
|
||||
| GET | `/admin/config.js` | 公开页面 | 运行时前端配置 |
|
||||
| GET | `/admin/js/main.js` | 公开页面 | 页面脚本入口 |
|
||||
| POST | `/admin/api/setup` | 仅用户表为空 | 创建第一个管理员 |
|
||||
| POST | `/admin/api/auth/login` | 未登录 | 用户名密码登录 |
|
||||
| POST | `/admin/api/auth/logout` | 已登录 | 注销当前会话 |
|
||||
| GET | `/admin/api/me` | 已登录 | 当前用户、角色和权限 |
|
||||
| GET | `/admin/api/status` | 成员、管理员 | 进程、端口、数据库、服务状态 |
|
||||
| GET | `/admin/api/routes` | 游客、成员、管理员 | 当前 SQLite 路由列表 |
|
||||
| PUT | `/admin/api/routes/{host}` | 成员、管理员 | 新增或更新一条路由 |
|
||||
| DELETE | `/admin/api/routes/{host}` | 成员、管理员 | 删除一条路由 |
|
||||
| GET | `/admin/api/services` | 成员、管理员 | 服务配置和运行状态 |
|
||||
| PUT | `/admin/api/services/{name}` | 管理员 | 修改 KCP、QUIC、WebSocket 配置 |
|
||||
| POST | `/admin/api/services/{name}/restart` | 管理员 | 重启指定服务 |
|
||||
| GET | `/admin/api/metrics` | 成员、管理员 | 连接和路由统计 |
|
||||
| GET | `/admin/api/users` | 管理员 | 用户列表 |
|
||||
| POST | `/admin/api/users` | 管理员 | 新增用户 |
|
||||
| PATCH | `/admin/api/users/{username}` | 管理员 | 修改用户角色、状态或密码 |
|
||||
| DELETE | `/admin/api/users/{username}` | 管理员 | 删除用户 |
|
||||
| GET | `/admin/api/audit-logs` | 管理员 | 审计日志 |
|
||||
|
||||
页面形态:
|
||||
|
||||
- 用户表为空时只显示初始化管理员界面。
|
||||
- 未登录且已初始化时只显示登录界面。
|
||||
- 成员和管理员顶部固定显示 gateway 状态、SQLite 路径和服务状态。
|
||||
- 路由表支持按 host、upstream、启用状态搜索。
|
||||
- 游客登录后只展示当前路由表,不展示新增、编辑、删除、服务配置、用户管理入口。
|
||||
- 新增、编辑、删除路由使用弹窗或行内表单,不单独跳转页面。
|
||||
- 删除 `default` 路由需要二次确认,因为它是 fallback 路由。
|
||||
- API 错误直接展示服务端返回的错误信息,便于运维判断问题。
|
||||
|
||||
## 用户与权限设计
|
||||
|
||||
第一版采用 SQLite 用户表和内存会话,不开放匿名管理 API。这里的“游客”是已登录用户的只读角色,不是未登录访问。
|
||||
|
||||
角色权限:
|
||||
|
||||
| 角色 | 权限 |
|
||||
| --- | --- |
|
||||
| 管理员 `admin` | 所有管理能力,包括用户管理、服务配置、路由管理、状态和指标查看 |
|
||||
| 成员 `member` | 查看状态、指标和服务状态;新增、修改、删除路由 |
|
||||
| 游客 `guest` | 只能登录、注销、查看自己的信息、查看当前 SQLite 路由 |
|
||||
|
||||
认证流程:
|
||||
|
||||
- `POST /admin/api/setup` 只在用户表为空时可用,用于创建第一个管理员。
|
||||
- `POST /admin/api/auth/login` 使用用户名和密码登录。
|
||||
- 登录成功后服务端生成随机会话 token,响应给前端。
|
||||
- 前端把会话 token 保存在 `sessionStorage`,后续 API 使用 `Authorization: Bearer <session_token>`。
|
||||
- 服务端在内存中保存会话,超过会话有效期后失效;进程重启后所有会话失效。
|
||||
- 未登录返回 `401`,已登录但权限不足返回 `403`。
|
||||
|
||||
用户规则:
|
||||
|
||||
- 密码只保存哈希,建议使用 bcrypt 或 argon2id,不保存明文密码。
|
||||
- 用户字段至少包含:`username`、`role`、`password_hash`、`disabled`、`created_at`、`updated_at`。
|
||||
- 管理员不能删除或禁用最后一个可用管理员账号。
|
||||
- 修改用户、重置密码、禁用用户都要记录审计日志。
|
||||
|
||||
## SQLite 存储设计
|
||||
|
||||
建议使用一个 SQLite 数据库保存所有后台状态。优先选择不依赖 CGO 的 SQLite driver,降低交叉编译和容器部署成本。
|
||||
|
||||
基础表:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
username TEXT PRIMARY KEY,
|
||||
role TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
disabled INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS routes (
|
||||
host TEXT PRIMARY KEY,
|
||||
upstream TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
updated_by TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS services (
|
||||
name TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL,
|
||||
port INTEGER NOT NULL DEFAULT 0,
|
||||
options_json TEXT NOT NULL DEFAULT '{}',
|
||||
restart_required INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
updated_by TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
actor TEXT NOT NULL,
|
||||
source_ip TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
success INTEGER NOT NULL,
|
||||
message 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);
|
||||
```
|
||||
|
||||
服务配置记录:
|
||||
|
||||
| name | 默认 enabled | 默认 port | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `tcp_admin` | 1 | 25565 | Minecraft TCP 和 Admin 共用入口 |
|
||||
| `kcp` | 0 | 25566 | KCP 服务 |
|
||||
| `quic` | 0 | 25565 | QUIC 服务,具体协议参数放在 `options_json` |
|
||||
| `websocket` | 0 | 25566 | WebSocket 服务,path 放在 `options_json` |
|
||||
|
||||
注意:
|
||||
|
||||
- `tcp_admin` 是基础入口,不允许在后台禁用。
|
||||
- `tcp_admin.port` 修改后第一版按重启后生效处理。
|
||||
- KCP、QUIC、WebSocket 配置修改后,如果当前实现无法安全热更新,则标记 `restart_required = 1` 并在页面提示。
|
||||
- 后续可以把服务运行状态放在内存中,不需要写入 SQLite。
|
||||
|
||||
SQLite 运行参数:
|
||||
|
||||
- 启动后设置 `PRAGMA journal_mode=WAL`。
|
||||
- 设置合理的 `busy_timeout`,避免后台写入和读取短暂冲突直接失败。
|
||||
- 所有写操作使用事务。
|
||||
- 路由和服务配置写入成功后,刷新对应的内存快照。
|
||||
|
||||
## 路由存储与运行时读取
|
||||
|
||||
路由管理以 SQLite 为唯一持久化来源。
|
||||
|
||||
运行时读取:
|
||||
|
||||
- 启动时从 SQLite 加载所有 `enabled = 1` 的路由,发布为内存只读快照。
|
||||
- `mapToHost` 从路由快照读取 host 到 upstream 的映射,不再读取 `config.Hosts`。
|
||||
- 路由 API 写入 SQLite 成功后,重新加载路由快照;不需要 reload。
|
||||
- 如果快照中没有目标 host,则尝试 `default`;没有 `default` 时按现有未命中逻辑关闭连接并记录日志。
|
||||
|
||||
写入流程:
|
||||
|
||||
1. 校验 host、upstream、enabled 和 note。
|
||||
2. 在 SQLite 事务中执行 insert、update、delete 或 enable/disable。
|
||||
3. 事务提交成功后重新加载路由快照。
|
||||
4. 快照发布成功后返回 API 成功。
|
||||
5. 如果快照发布失败,返回错误并保留 SQLite 已提交数据;旧路由快照继续服务已有逻辑。
|
||||
|
||||
并发与一致性:
|
||||
|
||||
- 后台写操作串行执行,避免并发修改同一 host 时互相覆盖。
|
||||
- 路由快照使用 copy-on-write 发布,转发热路径只做只读 map 查询。
|
||||
- 没有 `config.toml` 热加载,也不存在配置文件覆盖后台路由的问题。
|
||||
|
||||
## 路由校验规则
|
||||
|
||||
`host` 校验:
|
||||
|
||||
- 不能为空。
|
||||
- 允许 `default`。
|
||||
- 不允许包含空白字符。
|
||||
- 不允许包含 `/`,避免和 URL 路由混淆。
|
||||
|
||||
`upstream` 校验:
|
||||
|
||||
- 不能为空。
|
||||
- 支持现有前缀:无前缀 TCP、`kcp://`、`quic://`、`haproxy://`。
|
||||
- 去掉协议前缀后,必须能解析为 `host:port`。
|
||||
- 不在第一版新增 websocket upstream 前缀,因为当前 README 未列出该前缀的实际实现。
|
||||
|
||||
## 指标设计
|
||||
|
||||
第一版只做轻量统计,避免影响 TCP 转发热路径。
|
||||
|
||||
建议新增全局 `gatewayMetrics`,内部使用 `sync/atomic`:
|
||||
|
||||
- `total_connections`
|
||||
- `active_connections`
|
||||
- `tcp_connections`
|
||||
- `websocket_connections`
|
||||
- `route_hits{host}`
|
||||
- `route_misses`
|
||||
- `upstream_dial_errors`
|
||||
|
||||
实现原则:
|
||||
|
||||
- 只在连接进入、退出、路由成功或失败时更新计数。
|
||||
- 不在每次 `copyForward` 读写时计数。
|
||||
- route 维度只记录 host 计数,不记录完整客户端 IP。
|
||||
|
||||
## 测试计划
|
||||
|
||||
单元测试:
|
||||
|
||||
- 无配置文件时默认创建 SQLite 并写入默认服务配置。
|
||||
- 默认启动配置包含 `tcp_admin` enabled 和端口 `25565`。
|
||||
- Admin path、api_path 默认分别为 `/admin/` 和 `/admin/api`。
|
||||
- `MC_GATEWAY_TCP_ADMIN_PORT`、`MC_GATEWAY_ADMIN_PATH`、`MC_GATEWAY_ADMIN_API_PREFIX` 能覆盖启动期入口配置。
|
||||
- 启动期环境变量非法时返回明确错误,不创建含错误值的默认服务配置。
|
||||
- 首次初始化管理员、重复初始化拒绝、管理员登录和禁用用户拒绝登录。
|
||||
- 登录、注销、会话过期。
|
||||
- 权限 middleware 的 200、401、403 场景。
|
||||
- 管理员用户 API 的新增、改角色、重置密码、禁用和删除。
|
||||
- 不能删除或禁用最后一个可用管理员。
|
||||
- 路由 API 的新增、修改、删除和校验失败。
|
||||
- SQLite schema migration、空库初始化、WAL 和 busy timeout 配置。
|
||||
- 路由写入 SQLite 后能刷新内存路由快照。
|
||||
- 服务配置 API 能更新 KCP、QUIC、WebSocket 配置并标记是否需要重启。
|
||||
- 游客可以读取 routes,但不能写 routes、配置 services、读取 metrics 或 users。
|
||||
|
||||
集成测试:
|
||||
|
||||
- 不提供 `config.toml` 时,进程默认通过 `25565` 提供 TCP/Admin 入口。
|
||||
- 设置 `MC_GATEWAY_TCP_ADMIN_PORT` 和 Admin path/API prefix 后,进程通过指定端口和路径提供后台入口。
|
||||
- 设置自定义 Admin API prefix 后,登录、状态、路由等 API 都挂载到新的 prefix 下。
|
||||
- 用户表为空时可访问初始化页面并创建第一个管理员。
|
||||
- 管理员或成员登录后可访问 `/admin/api/status`。
|
||||
- 游客账号登录后可以通过 `25565` 读取 `/admin/api/routes`。
|
||||
- 成员通过后台新增或修改路由后,新的 Minecraft host 立即按 SQLite 记录转发。
|
||||
- 同一端口下 Minecraft 握手仍然进入 TCP 分支,并能完成 host 路由。
|
||||
- WebSocket 启用后,按后台配置的端口和 path 提供服务。
|
||||
|
||||
性能验证:
|
||||
|
||||
- 管理页面变更后继续运行现有 TCP 转发 benchmark。
|
||||
- 重点确认路由快照查询和指标计数没有进入已建立连接后的 `copyForward` 热路径。
|
||||
|
||||
建议命令:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
go test -race ./...
|
||||
go test -run TestTcpWebPortReuse ./cmd/gateway
|
||||
go test -bench 'Benchmark.*tcp' ./cmd/gateway
|
||||
```
|
||||
|
||||
## 实施步骤
|
||||
|
||||
1. 移除 `config.toml` 启动依赖,改为无配置默认值启动。
|
||||
2. 新增 SQLite 打开、schema migration、WAL 和默认服务配置初始化。
|
||||
3. 增加启动期环境变量解析和校验,覆盖 TCP/Admin 端口、Admin 页面路径和 Admin API 前缀。
|
||||
4. 将 TCP/Admin 共享 listener 固定为基础入口,默认端口 `25565`,并支持启动期端口覆盖。
|
||||
5. 把 WebSocket handler 创建逻辑扩展为统一 `newGatewayHTTPHandler()`。
|
||||
6. 增加用户表、首次初始化管理员、密码哈希和会话管理。
|
||||
7. 增加 Admin 静态页面、初始化页面、登录页面和权限 middleware。
|
||||
8. 增加 auth、me、users、status、routes、services、metrics、audit-logs API。
|
||||
9. 将 `mapToHost` 改为读取 SQLite 路由快照,不再读取 `config.Hosts`。
|
||||
10. 实现 routes 写入 SQLite、事务提交和路由快照刷新。
|
||||
11. 实现 services 写入 SQLite,并接入 KCP、QUIC、WebSocket 启动配置。
|
||||
12. 实现 users 写入 SQLite 和权限校验。
|
||||
13. 移除或废弃配置文件 watcher 和基于文件的 reload 逻辑。
|
||||
14. 补充测试和 benchmark 验证。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 没有 `config.toml` 时,gateway 默认在 `25565` 启动 TCP 转发和后台管理页面。
|
||||
- `http://<host>:25565/admin/` 可访问初始化或登录页面。
|
||||
- 设置 `MC_GATEWAY_TCP_ADMIN_PORT`、`MC_GATEWAY_ADMIN_PATH`、`MC_GATEWAY_ADMIN_API_PREFIX` 后,后台入口使用环境变量指定的端口和路径。
|
||||
- 同一端口下 Minecraft 客户端连接和 host 路由行为正常。
|
||||
- 用户表为空时只能创建第一个管理员;创建后初始化接口不可再次使用。
|
||||
- 未登录的管理 API 请求返回 `401`,已登录但权限不足返回 `403`。
|
||||
- 管理员可以新增用户、禁用用户、修改角色和重置密码。
|
||||
- 管理员可以配置 KCP、QUIC、WebSocket 服务。
|
||||
- 成员可以查看、搜索、新增、修改、删除 SQLite 路由记录,变更后立即影响新连接路由。
|
||||
- 游客可以查看和搜索当前 SQLite 路由记录,但看不到写操作入口,直接调用写 API 返回 `403`。
|
||||
- 删除或修改旧 `config.toml` 不影响后台管理中的用户、服务配置和路由。
|
||||
- SQLite 写入失败或路由快照刷新失败时,页面展示明确错误,旧路由快照仍可继续工作。
|
||||
- `go test ./...`、`go test -race ./...` 和 TCP benchmark 完成后无新增失败。
|
||||
493
docs/plugin-development-toolchain-design.md
Normal file
493
docs/plugin-development-toolchain-design.md
Normal file
@@ -0,0 +1,493 @@
|
||||
# 插件开发工具链设计
|
||||
|
||||
本文定义插件开发工具链的功能需求和实现边界。目标是让插件作者从新建、开发、测试、打包到发布前检查都使用同一套 `gateway plugin` CLI,而不是在每个示例插件里维护重复脚本。
|
||||
|
||||
本设计以 [plugin-system-design.md](plugin-system-design.md) 和 [plugin-implementation-plan.md](plugin-implementation-plan.md) 为上游约束。插件作者只维护一个 manifest source 文件,支持 `manifest.yaml`、`manifest.yml`、`manifest.toml`、`manifest.jsonc` 或 `manifest.json`;`.mcgp` 包内仍统一物化为 `manifest.json`。Go 代码中不再维护 `manifestJSON` 或等价重复元数据。
|
||||
|
||||
## 目标
|
||||
|
||||
- 提供 `gateway plugin init/build/test` 三个核心开发入口。
|
||||
- 让示例插件和第三方插件使用同一套构建、打包、校验和测试流程。
|
||||
- 支持 binary `.mcgp` 和 source `.mcgp`,并逐步替代示例插件内的 `build.sh`、`cmd/render-manifest` 等重复逻辑。
|
||||
- 保持工具链 runtime-neutral:Go plugin 是第一批实现目标,后续 `go-plugin-process`、`sandbox-process`、WASM 和 ingress service 通过 runtime adapter 扩展。
|
||||
- 保证 CLI 产物可被 Admin/API 的服务端校验重复验证;CLI 只是开发体验和预检工具,不是信任边界。
|
||||
- 产物尽量稳定可复现:相同输入、相同 builder 和相同环境生成相同 zip 排序、权限和摘要。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不引入 `gateway plugin dev ...` 命名空间;开发命令直接扩展在 `gateway plugin` 下。
|
||||
- 不恢复代码内 manifest 元数据。
|
||||
- 不支持插件自定义构建脚本作为默认路径。
|
||||
- 不把 source build 当成 runtime sandbox。
|
||||
- 不在第一版支持远程插件市场、签名分发或自动升级。
|
||||
- 不承诺 Go plugin 真正热卸载;本地调试仍遵守运行时限制。
|
||||
|
||||
## 设计决策
|
||||
|
||||
| 决策 | 结论 |
|
||||
| --- | --- |
|
||||
| CLI 命名 | 直接扩展 `gateway plugin init/build/test`,不新增 `dev` 子命名空间 |
|
||||
| 元数据来源 | 插件目录只允许一个人工维护的 manifest source;包内可信元数据统一为 canonical `manifest.json` |
|
||||
| 打包入口 | `gateway plugin build` 同时承担 build 和 package,不再要求插件目录自带 zip 脚本 |
|
||||
| 示例插件 | `upstream-rewrite` 和 `mc-auth-proxy` 迁移到标准 CLI,删除重复 `build.sh` 和 `render-manifest` 逻辑 |
|
||||
| runtime 扩展 | CLI 通过 runtime build/test adapter 分发逻辑,命令名不随 runtime 改变 |
|
||||
| 校验边界 | CLI 校验不能替代 gateway 服务端上传、构建、准入和 enable 校验 |
|
||||
| source manifest | 源码目录中的 `manifest.yaml/yml/toml/jsonc/json` 是作者输入;artifact 包内的 `manifest.json` 是构建时物化结果,不作为第二份人工维护数据 |
|
||||
|
||||
## 命令总览
|
||||
|
||||
第一版重点实现:
|
||||
|
||||
| 命令 | 用途 |
|
||||
| --- | --- |
|
||||
| `gateway plugin init <dir>` | 生成插件模板 |
|
||||
| `gateway plugin build [dir]` | 构建并打包 binary/source `.mcgp` |
|
||||
| `gateway plugin test [dir]` | 运行插件单元测试和 harness 测试 |
|
||||
| `gateway plugin validate <path>` | 校验 manifest、源码目录或 `.mcgp` 包 |
|
||||
| `gateway plugin inspect <artifact.mcgp>` | 查看包内 manifest 和摘要 |
|
||||
| `gateway plugin compat <artifact.mcgp>` | 检查当前 gateway 对 artifact 的兼容性 |
|
||||
|
||||
现有 `gateway plugin source-build <source.mcgp> [out.mcgp]` 保留为兼容命令。后续可以由 `gateway plugin build --from-source <source.mcgp> --out <out.mcgp>` 覆盖同等能力,再把 `source-build` 标记为兼容别名。
|
||||
|
||||
所有面向 CI 的命令都应支持:
|
||||
|
||||
- `--json`:输出机器可读结果。
|
||||
- `--quiet`:只输出错误或关键产物路径。
|
||||
- `--out <path>`:指定产物或报告位置。
|
||||
- 稳定退出码:参数错误、校验失败、构建失败和测试失败应可区分。
|
||||
|
||||
## 完整功能域
|
||||
|
||||
工具链最终需要覆盖从插件作者到生产运维的完整闭环。下表是功能需求清单,阶段表示推荐落地顺序,不代表命令只能在该阶段出现。
|
||||
|
||||
| 功能域 | 需要解决的问题 | 关键命令 |
|
||||
| --- | --- | --- |
|
||||
| 项目脚手架 | 快速生成可构建、可测试、manifest 正确的插件目录 | `init` |
|
||||
| Manifest 编辑 | 发现字段错误、解释支持能力、避免人工维护环境字段 | `validate`、`manifest format`、`manifest explain`、`features` |
|
||||
| 构建和打包 | 统一 binary/source `.mcgp` 产物,替代示例脚本 | `build`、`clean` |
|
||||
| Source 构建复现 | 在本地或 CI 复现 gateway builder 行为 | `build --from-source` |
|
||||
| 单元和契约测试 | 在真实上传前验证 SDK、extension point 和 fixture | `test`、`conformance` |
|
||||
| 本地安装调试 | 把产物上传到开发 gateway,启用、禁用、回滚和查看状态 | `upload`、`enable`、`disable`、`rollback`、`status` |
|
||||
| 配置和 secret 预检 | 在启用前验证 config schema、secret ref、reload 兼容性 | `config validate`、`secret check`、`preflight` |
|
||||
| 发布门禁 | 生成能进入 review/CI 的证据 | `preflight`、`self-test`、`benchmark` |
|
||||
| 观测诊断 | 收集插件日志、事件、指标、trace 和诊断包 | `logs`、`events`、`metrics`、`diagnose` |
|
||||
| 后台任务 | 开发和运维手动触发任务、查看执行状态 | `task list`、`task run`、`task cancel` |
|
||||
| 数据和文件 | 查看 plugin_data/runtime files 配额、导出可迁移数据、GC | `data inspect/export/gc`、`files inspect/export/gc` |
|
||||
| Promotion | 跨环境导入导出、diff、drift 和灾备演练 | `export`、`import`、`diff`、`drift`、`dr-drill` |
|
||||
| 仓库和供应链 | 导入仓库候选、验证 SBOM/license/signature/advisory | `repo`、`sbom`、`sign`、`verify`、`advisory` |
|
||||
| SDK 和契约治理 | 发布前检查 SDK/API/manifest/错误码兼容性 | `contract check`、`schema export`、`conformance` |
|
||||
| Runtime 扩展 | 让新 runtime 复用同一套 init/build/test/validate 命令 | runtime adapter、`runtime features` |
|
||||
|
||||
### 命令分层
|
||||
|
||||
为了避免第一版实现过大,命令按层交付:
|
||||
|
||||
| 层级 | 阶段 | 命令 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 0 | 已有能力 | `inspect`、`validate`、`compat`、`source-validate`、`source-build` | 当前 CLI 基线,后续保持兼容 |
|
||||
| 1 | 阶段 1-3 | `init`、`build`、`test`、`features`、`manifest format/explain` | 插件作者日常开发闭环 |
|
||||
| 2 | 阶段 4 | `upload`、`enable`、`disable`、`rollback`、`status`、`config validate`、`secret check` | 本地开发 gateway 和 Admin API 操作闭环 |
|
||||
| 3 | 阶段 5 | `preflight`、`self-test`、`benchmark`、`review status`、`advisory scan` | 发布治理和准入证据 |
|
||||
| 4 | 阶段 6 | `logs`、`events`、`metrics`、`diagnose`、`task`、`data`、`files`、`gc` | 运行诊断、后台任务、数据和资源治理 |
|
||||
| 5 | 阶段 7-8 | `repo`、`sbom`、`sign`、`verify`、`contract`、`conformance`、`export/import/diff/drift/dr-drill` | 生态、供应链、跨环境发布和未来 runtime |
|
||||
|
||||
第一版不必一次实现所有命令,但设计上要避免把能力做进一次性脚本。每个命令都应能输出 JSON 报告,方便 CI 和 Admin API 复用。
|
||||
|
||||
## 开发工作流
|
||||
|
||||
工具链需要支持这些端到端流程。
|
||||
|
||||
### 新插件开发
|
||||
|
||||
```sh
|
||||
gateway plugin init ./my-plugin --id my-plugin --template upstream-dialer --module example.com/my-plugin
|
||||
cd ./my-plugin
|
||||
gateway plugin validate .
|
||||
gateway plugin test .
|
||||
gateway plugin build . --type both
|
||||
gateway plugin compat dist/my-plugin.mcgp
|
||||
```
|
||||
|
||||
完成标准:
|
||||
|
||||
- 不需要手写 zip 命令。
|
||||
- 不需要手写 `render-manifest`。
|
||||
- 不需要在 Go 代码中声明 manifest 元数据。
|
||||
- 默认模板生成 `manifest.yaml`;如需其它格式可使用 `gateway plugin init --manifest-format yaml|toml|jsonc|json`。
|
||||
|
||||
### 本地调试
|
||||
|
||||
```sh
|
||||
gateway plugin build . --type binary
|
||||
gateway plugin upload dist/my-plugin.mcgp --gateway http://127.0.0.1:8080
|
||||
gateway plugin enable my-plugin --config testdata/config.json --profile dev
|
||||
gateway plugin status my-plugin
|
||||
gateway plugin logs my-plugin --tail 100
|
||||
gateway plugin disable my-plugin
|
||||
```
|
||||
|
||||
本地调试命令通过 Admin API 工作,不绕过服务端校验。需要认证时使用现有 Admin session/token 机制;CLI 不保存 secret 明文。
|
||||
|
||||
### CI 发布检查
|
||||
|
||||
```sh
|
||||
gateway plugin validate .
|
||||
gateway plugin test . --profile unit,manifest,harness,protocol-smoke
|
||||
gateway plugin build . --type both --json --out dist/build-report.json
|
||||
gateway plugin compat dist/my-plugin.mcgp --json --out dist/compat-report.json
|
||||
gateway plugin preflight dist/my-plugin.mcgp --config config/prod.json --profile prod --json
|
||||
gateway plugin benchmark dist/my-plugin.mcgp --profile ci-contract --json
|
||||
```
|
||||
|
||||
CI 报告必须能作为 review 证据保存,并包含 artifact sha256、source sha256、SDK/API 版本、runtime、extension points、config hash、测试 profile 和失败原因。
|
||||
|
||||
### Source 包复现
|
||||
|
||||
```sh
|
||||
gateway plugin build . --type source
|
||||
gateway plugin build --from-source dist/my-plugin-source.mcgp --out dist/my-plugin-rebuilt.mcgp
|
||||
gateway plugin compat dist/my-plugin-rebuilt.mcgp
|
||||
```
|
||||
|
||||
该流程用于验证源码包能被受控 builder 重建,且构建失败不会影响 active artifact。
|
||||
|
||||
### 跨环境发布
|
||||
|
||||
```sh
|
||||
gateway plugin export my-plugin --profile staging --out promotion.json
|
||||
gateway plugin diff promotion.json --target prod
|
||||
gateway plugin import promotion.json --target prod --dry-run
|
||||
gateway plugin drift --baseline promotion.json --target prod
|
||||
```
|
||||
|
||||
promotion bundle 默认不包含 secret 明文、secret 密文和 runtime state。缺失 secret mapping、runtime 不兼容、advisory 命中或策略阻断时必须失败。
|
||||
|
||||
## `gateway plugin init`
|
||||
|
||||
`init` 负责生成一个可直接构建和测试的插件目录。
|
||||
|
||||
### 输入
|
||||
|
||||
推荐参数:
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| `--id <id>` | 插件 ID,必须满足 manifest 命名规则 |
|
||||
| `--name <name>` | 展示名,默认由 ID 派生 |
|
||||
| `--template <name>` | 模板名 |
|
||||
| `--runtime <type>` | runtime 类型,默认 `go-plugin` |
|
||||
| `--module <module>` | Go module path,Go runtime 模板必填或由目录推导 |
|
||||
| `--extension <key>` | 目标 extension point |
|
||||
|
||||
第一批模板:
|
||||
|
||||
| 模板 | runtime | extension point | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `upstream-dialer` | `go-plugin` | `upstream.connect/v1` | 最小 dialer mode 模板 |
|
||||
| `protocol-proxy` | `go-plugin` | `upstream.connect/v1` | 最小 Minecraft protocol-proxy 模板 |
|
||||
| `empty-go` | `go-plugin` | 无默认 handler | 用于自定义实验 |
|
||||
|
||||
预留模板:
|
||||
|
||||
| 模板 | runtime | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `wasm-rule` | `wasm` | 未来 rule/config validate 类轻量插件 |
|
||||
| `sandbox-process` | `sandbox-process` | 未来隔离进程插件 |
|
||||
| `ingress-service` | `sandbox-process` 或专用 runtime | 未来入口服务插件 |
|
||||
|
||||
### 输出目录
|
||||
|
||||
Go plugin 模板应至少生成:
|
||||
|
||||
- `manifest.yaml`(默认;也支持 `manifest.yml`、`manifest.toml`、`manifest.jsonc`、`manifest.json`)
|
||||
- `go.mod`
|
||||
- `main.go`
|
||||
- `main_test.go`
|
||||
- `README.md`
|
||||
- `testdata/config.json`
|
||||
- `testdata/fixtures/`,按模板放置 harness 输入
|
||||
|
||||
生成的 manifest source 只包含作者应该维护的字段。`go_version`、`go_os`、`go_arch` 等环境相关字段可以为空或使用文档化占位;`build` 时再物化到 artifact manifest。
|
||||
|
||||
## `gateway plugin build`
|
||||
|
||||
`build` 是统一构建和打包入口。
|
||||
|
||||
### 常用模式
|
||||
|
||||
| 命令 | 结果 |
|
||||
| --- | --- |
|
||||
| `gateway plugin build .` | 默认生成 binary `.mcgp` |
|
||||
| `gateway plugin build . --type binary` | 生成 binary `.mcgp` |
|
||||
| `gateway plugin build . --type source` | 生成 source `.mcgp` |
|
||||
| `gateway plugin build . --type both` | 同时生成 binary 和 source `.mcgp` |
|
||||
| `gateway plugin build --from-source source.mcgp --out built.mcgp` | 使用 gateway builder 从 source 包生成 binary 包 |
|
||||
|
||||
当源码目录内存在多个 `manifest.*` 文件,`build` 必须通过 `--manifest <path>` 显式选择源文件;同一规则也适用于 `test`、`validate`、`preflight`、`self-test`、`benchmark` 和 `manifest format`。
|
||||
|
||||
推荐默认输出:
|
||||
|
||||
- `dist/<plugin-id>.mcgp`
|
||||
- `dist/<plugin-id>-source.mcgp`
|
||||
- `dist/<plugin-id>-built.mcgp`
|
||||
- `dist/build-report.json`
|
||||
|
||||
### Manifest 物化规则
|
||||
|
||||
源码目录中只能存在一个 manifest source 文件。`build` 读取 `manifest.yaml/yml/toml/jsonc/json` 后在内存中生成 artifact manifest,并写入 `.mcgp` 包内的 canonical `manifest.json`:
|
||||
|
||||
- `artifact_type` 按 `--type` 写为 `binary` 或 `source`。
|
||||
- binary 包写入 `runtime.entry=plugin.so`。
|
||||
- Go plugin binary 包写入实际 `go_version`、`go_os`、`go_arch`。
|
||||
- source 包写入 `build.type=go`、`build.entry`、`build.output`、`build.tags` 和 vendor 策略。
|
||||
- 构建 provenance、module summary、artifact sha256 等写入 build report 或服务端 build record,不要求回写源码目录的 manifest source。
|
||||
|
||||
这保证源码仓库里没有第二份需要维护的 manifest,也避免 manifest source 与 Go 代码常量不一致。
|
||||
|
||||
如果目录中同时存在多个 `manifest.*` 文件,CLI 必须失败并要求传入 `--manifest <path>` 显式选择,避免不同格式的 manifest 分叉。`gateway plugin manifest format --canonical-json --type binary|source` 可查看最终写入对应 `.mcgp` 的规范 JSON;不传 `--type` 时使用 manifest source 中的 `artifact_type`,缺省按 binary 处理。`--write` 对 YAML/TOML/JSONC 必须保留注释,无法保留时不能覆盖源文件。
|
||||
|
||||
### Go Plugin Adapter
|
||||
|
||||
第一版 `go-plugin` build adapter 负责:
|
||||
|
||||
1. 读取并校验唯一 manifest source,或通过 `--manifest` 指定的 manifest source。
|
||||
2. 运行 `go test ./...`,除非传入 `--skip-tests`。
|
||||
3. 用固定命令构建 `plugin.so`:`go build -buildmode=plugin -trimpath -buildvcs=false`。
|
||||
4. 用 `go tool nm` 校验 `Plugin` 符号。
|
||||
5. 生成稳定 zip:固定 entry 排序、权限、时间戳策略和路径分隔符。
|
||||
6. 生成 source `.mcgp` 时只包含允许的源码、`go.mod`、可选 `go.sum/vendor`、README、LICENSE、SBOM 和测试 fixture。
|
||||
7. 输出 artifact sha256、source sha256、Go/API/SDK 版本和 ABI fingerprint。
|
||||
|
||||
第一版不执行包内脚本。未来如果需要复杂构建,应通过受控 builder profile 或外部 CI,而不是让插件包携带任意 shell 脚本。
|
||||
|
||||
### Runtime Adapter 预留
|
||||
|
||||
CLI 内部应抽象 build adapter:
|
||||
|
||||
```go
|
||||
type PluginBuildAdapter interface {
|
||||
RuntimeType() string
|
||||
ValidateSource(ctx context.Context, req BuildCLIRequest) error
|
||||
Build(ctx context.Context, req BuildCLIRequest) (BuildCLIResult, error)
|
||||
PackageSource(ctx context.Context, req BuildCLIRequest) (BuildCLIResult, error)
|
||||
}
|
||||
```
|
||||
|
||||
预留 runtime 行为:
|
||||
|
||||
| runtime | build 产物 | source 包 | 测试方式 |
|
||||
| --- | --- | --- | --- |
|
||||
| `go-plugin` | `plugin.so` | Go module source | Go test + extension harness |
|
||||
| `go-plugin-process` | `plugin.so` 或 host bundle | Go module source | 子进程 host harness |
|
||||
| `sandbox-process` | executable 或 bundle | 受控源码/二进制 bundle | control RPC harness |
|
||||
| `wasm` | `plugin.wasm` | WASM source/bundle | WASM host ABI harness |
|
||||
| `builtin` | 无外部 artifact | 不适用 | gateway 内部测试 |
|
||||
|
||||
命令层不应写死 Go plugin 细节。新增 runtime 时只新增 adapter、manifest 校验和 harness,不新增一套用户命令。
|
||||
|
||||
## `gateway plugin test`
|
||||
|
||||
`test` 负责把插件作者的本地测试和 gateway extension contract 连接起来。
|
||||
|
||||
### 测试 profile
|
||||
|
||||
| Profile | 说明 |
|
||||
| --- | --- |
|
||||
| `unit` | 运行插件目录原生测试,例如 `go test ./...` |
|
||||
| `manifest` | 校验 manifest schema、命名、runtime、extension point 和 config schema |
|
||||
| `harness` | 运行 extension point fixture |
|
||||
| `protocol-smoke` | 运行 Minecraft handshake/login smoke fixture |
|
||||
| `conformance` | 运行当前 gateway 公开契约兼容测试 |
|
||||
|
||||
常用命令:
|
||||
|
||||
| 命令 | 结果 |
|
||||
| --- | --- |
|
||||
| `gateway plugin test .` | 运行模板默认 profile |
|
||||
| `gateway plugin test . --profile unit,harness` | 运行指定 profile |
|
||||
| `gateway plugin test . --config testdata/config.json` | 使用指定配置测试 |
|
||||
| `gateway plugin test . --fixture testdata/fixtures/login-reject.json` | 使用指定 fixture |
|
||||
| `gateway plugin test dist/plugin.mcgp --profile compat` | 对已打包 artifact 做兼容测试 |
|
||||
|
||||
### Harness 范围
|
||||
|
||||
第一版 harness 覆盖:
|
||||
|
||||
- `upstream.connect/v1` dialer mode:匹配 host、返回 `api.ErrPass`、返回自管 conn、错误传播。
|
||||
- `upstream.connect/v1` protocol-proxy mode:initial data replay、handshake/login packet fixture、disconnect/kick 响应、读写关闭。
|
||||
- config:`ReloadConfig()` 成功、失败、默认值和 schema 校验。
|
||||
- lifecycle:`Init()`、`Destroy()` 幂等、handler timeout、panic recover。
|
||||
|
||||
未来 runtime harness:
|
||||
|
||||
- `go-plugin-process`:通过 plugin-host 启动插件,验证 drain-only、crash loop 和 control channel。
|
||||
- `sandbox-process`:验证 capability enforcement、secret handle、filesystem/network policy。
|
||||
- `wasm`:验证 host ABI、memory/time limit、无授权文件和网络访问。
|
||||
- `ingress.service/v1`:验证 listener 由 gateway 创建、端口冲突和 disable drain。
|
||||
|
||||
## `gateway plugin validate`
|
||||
|
||||
`validate` 应支持三类输入:
|
||||
|
||||
- manifest source 文件:`manifest.yaml`、`manifest.yml`、`manifest.toml`、`manifest.jsonc` 或 `manifest.json`
|
||||
- 插件源码目录
|
||||
- `.mcgp` artifact
|
||||
|
||||
校验内容:
|
||||
|
||||
- manifest schema 和必填字段。
|
||||
- runtime type、runtime entry、build entry。
|
||||
- extension point key、type 和 mode。
|
||||
- config schema JSON。
|
||||
- secret、event、metric、background task、external dependency、data store 和 file store 命名。
|
||||
- binary/source 包结构、zip slip、大小限制和允许文件。
|
||||
- 当前 gateway feature support。
|
||||
|
||||
对于源码目录,`validate` 不能执行插件代码;最多做静态文件、manifest 和包结构检查。需要运行代码的检查放在 `test` 或 `build`。
|
||||
|
||||
## 本地 Admin 操作命令
|
||||
|
||||
阶段 4 后,CLI 应能操作开发或测试环境的 Admin API,形成不依赖页面的调试闭环。
|
||||
|
||||
| 命令 | 职责 |
|
||||
| --- | --- |
|
||||
| `gateway plugin upload <artifact.mcgp>` | 上传 artifact/source package,返回 artifact ID、sha256 和校验摘要 |
|
||||
| `gateway plugin status [plugin-id]` | 展示 desired/runtime state、active/desired/loaded artifact、recent error 和 restart required |
|
||||
| `gateway plugin enable <plugin-id>` | 设置 desired enabled,支持 `--artifact`、`--config`、`--profile`、`--priority` |
|
||||
| `gateway plugin disable <plugin-id>` | 设置 desired disabled,protocol-proxy 连接按策略 drain 或 force close |
|
||||
| `gateway plugin delete <plugin-id>` | 删除 desired state 或 artifact,支持保留/删除数据选项 |
|
||||
| `gateway plugin rollback <plugin-id>` | 回滚 artifact 或 config snapshot,并重新执行当前基础门禁 |
|
||||
| `gateway plugin config validate <plugin-id>` | 校验 config JSON、schema、secret ref 和 `ReloadConfig()` dry-run |
|
||||
| `gateway plugin secret check <plugin-id>` | 检查 manifest 必需 secret、secret ref、版本和 reload/rotation 状态 |
|
||||
|
||||
这些命令必须通过 Admin API 执行,并复用服务端权限、审计和错误码。CLI 不直接写 SQLite,不直接操作 artifact store,也不能绕过上传时的 zip/manifest 校验。
|
||||
|
||||
## 发布治理命令
|
||||
|
||||
阶段 5 后,CLI 需要生成和读取生产准入证据。
|
||||
|
||||
| 命令 | 职责 |
|
||||
| --- | --- |
|
||||
| `gateway plugin preflight` | 运行 config、secret、feature、runtime limits、scope/rollout、conflict 和 Minecraft capability 检查 |
|
||||
| `gateway plugin self-test` | 运行插件实现的 quick/protocol-smoke/integration profile,保存脱敏证据 |
|
||||
| `gateway plugin benchmark` | 记录或执行 benchmark profile,输出 P95/P99、error rate、capacity 和 baseline diff |
|
||||
| `gateway plugin review status` | 查看当前 artifact/config/scope/risk/policy hash 是否已有有效 review |
|
||||
| `gateway plugin advisory scan` | 按 artifact sha256、plugin/version、SBOM dependency 或 source metadata 扫描安全公告 |
|
||||
|
||||
发布治理命令的 JSON 报告必须包含稳定 `code`、`severity`、`message`、`evidence_id` 和相关 hash,不能要求 CI 解析人类可读文本。
|
||||
|
||||
## 观测和运维命令
|
||||
|
||||
阶段 6 后,CLI 应覆盖插件出问题时的定位、证据导出和资源清理。
|
||||
|
||||
| 命令 | 职责 |
|
||||
| --- | --- |
|
||||
| `gateway plugin logs <plugin-id>` | 查看插件日志摘要,支持 tail、时间范围、trace ID 和脱敏 |
|
||||
| `gateway plugin events <plugin-id>` | 查看插件业务事件、drop/dead-letter 摘要和 replay/drop 操作 |
|
||||
| `gateway plugin metrics <plugin-id>` | 查看 handler calls、duration、panic、timeout、active proxy connections 和 custom metrics |
|
||||
| `gateway plugin diagnose <plugin-id>` | 生成诊断包,包含 manifest、state、recent logs/events/metrics/build summary,不含 secret 明文 |
|
||||
| `gateway plugin task list/run/cancel <plugin-id>` | 查看、手动触发或取消 background task |
|
||||
| `gateway plugin data inspect/export/gc <plugin-id>` | 查看 plugin_data schema/data class/quota,导出可迁移数据,执行 dry-run 或清理 |
|
||||
| `gateway plugin files inspect/export/gc <plugin-id>` | 查看 runtime files/resources/cache/tmp/log/diagnostic 用量和 GC candidate |
|
||||
| `gateway plugin gc --dry-run` | 汇总 artifact、build log、diagnostic、plugin_data 和 runtime files 的可清理对象 |
|
||||
|
||||
所有清理命令默认 dry-run;实际删除必须显式传入确认参数,并写审计。数据导出只允许 manifest 声明 `exportable=true` 且调用者有权限的数据。
|
||||
|
||||
## 仓库、供应链和签名命令
|
||||
|
||||
阶段 8 的分发能力不能绕过本地 review 和 enable 流程。
|
||||
|
||||
| 命令 | 职责 |
|
||||
| --- | --- |
|
||||
| `gateway plugin repo list/search/show` | 查看 official/internal/file/url repository 中的候选版本 |
|
||||
| `gateway plugin repo import` | 下载或导入候选 artifact 到本地 store,只生成 local artifact,不自动启用 |
|
||||
| `gateway plugin sbom generate/verify` | 生成或验证 SBOM,供 advisory/license 策略使用 |
|
||||
| `gateway plugin sign` | 对 artifact 或 promotion bundle 签名,未来能力 |
|
||||
| `gateway plugin verify` | 验证 signature、sha256、SBOM、license 和 provenance |
|
||||
| `gateway plugin advisory import/scan/ack` | 导入安全公告、重新扫描本地 artifact、记录 mitigation/ack |
|
||||
|
||||
仓库删除、远端更新或签名失败都不能自动改变本地 active artifact。repository import 之后仍要走 validate、compat、preflight、review 和 enable。
|
||||
|
||||
## 契约和 SDK 命令
|
||||
|
||||
插件系统公开 API 后,CLI 还要服务 gateway release 过程。
|
||||
|
||||
| 命令 | 职责 |
|
||||
| --- | --- |
|
||||
| `gateway plugin features` | 输出当前 gateway 支持的 runtime、extension point、manifest field、feature key 和版本 |
|
||||
| `gateway plugin schema export` | 导出 manifest JSON schema、config UI hint schema 和 extension fixture schema |
|
||||
| `gateway plugin contract check` | 对比上一 release 的 SDK/API/manifest/error code/CLI JSON 输出兼容性 |
|
||||
| `gateway plugin conformance` | 构建示例插件,运行 source/binary fixture 和 Admin/CLI golden test |
|
||||
|
||||
`features` 输出必须和 Admin API 使用同一契约。`contract check` 和 `conformance` 失败应被视为 gateway release 风险,不是普通文档错误。
|
||||
|
||||
## Runtime 扩展命令
|
||||
|
||||
新增 runtime 不应增加一套平行 CLI。`init/build/test/validate/compat/preflight` 必须根据 `manifest.runtime.type` 选择 adapter。
|
||||
|
||||
| runtime | 额外 CLI 需求 |
|
||||
| --- | --- |
|
||||
| `go-plugin-process` | `test` 能启动 plugin-host harness;`preflight` 检查 migration mode、safe point、drain-only/fd-live 声明 |
|
||||
| `sandbox-process` | `validate/preflight` 检查 capability、secret handle、filesystem/network/env/cpu/memory policy;`test` 验证 control RPC 和 crash loop |
|
||||
| `wasm` | `build` 生成 `plugin.wasm`;`test` 使用 WASM host ABI;`preflight` 检查 memory/time/no file/no network |
|
||||
| `ingress.service/v1` | `preflight` 检查 listener ownership、port conflict、TLS/secret refs 和 disable drain |
|
||||
| build-time instrumentation | 不进入 runtime plugin enable/disable;CLI 只提供 manifest/provenance/conformance/benchmark/smoke 证据 |
|
||||
|
||||
如果目标 gateway 不支持某 runtime,`compat` 和 `preflight` 必须返回明确的 blocking code,而不是降级为 Go plugin 尝试加载。
|
||||
|
||||
## 发布前检查
|
||||
|
||||
发布前推荐流程:
|
||||
|
||||
1. `gateway plugin validate .`
|
||||
2. `gateway plugin test . --profile unit,manifest,harness`
|
||||
3. `gateway plugin build . --type both`
|
||||
4. `gateway plugin validate dist/<plugin-id>.mcgp`
|
||||
5. `gateway plugin compat dist/<plugin-id>.mcgp`
|
||||
6. 可选:`gateway plugin build --from-source dist/<plugin-id>-source.mcgp --out dist/<plugin-id>-rebuilt.mcgp`
|
||||
7. 可选:`gateway plugin test dist/<plugin-id>.mcgp --profile conformance`
|
||||
|
||||
CI 产物应至少保存:
|
||||
|
||||
- binary `.mcgp`
|
||||
- source `.mcgp`
|
||||
- build report JSON
|
||||
- test report JSON
|
||||
- artifact sha256 和 source sha256
|
||||
|
||||
## 示例插件迁移
|
||||
|
||||
`examples/plugins/upstream-rewrite` 和 `examples/plugins/mc-auth-proxy` 迁移目标:
|
||||
|
||||
- README 使用 `gateway plugin build . --type both`。
|
||||
- README 使用 `gateway plugin test .`。
|
||||
- 删除或降级 `build.sh` 为兼容包装;最终不再作为主路径。
|
||||
- 删除 `cmd/render-manifest`,由 CLI 根据源码 manifest source 生成 artifact manifest。
|
||||
- 示例插件的测试 fixture 进入 `testdata/fixtures/`。
|
||||
- 示例插件进入 conformance suite;构建失败视为插件 API 回归。
|
||||
|
||||
迁移时必须保留现有 `.mcgp` 格式:binary 包仍包含 `manifest.json` 和 `plugin.so`;source 包仍包含 `manifest.json`、`go.mod`、build entry 和源码。
|
||||
|
||||
## 实现顺序
|
||||
|
||||
建议按以下顺序实现:
|
||||
|
||||
1. 增加 `gateway plugin init`,生成 `upstream-dialer` 和 `protocol-proxy` Go 模板。
|
||||
2. 增加 `gateway plugin build` 的 Go plugin binary/source 打包能力,复用现有 artifact 校验逻辑。
|
||||
3. 用 `gateway plugin build` 替换示例插件 `build.sh` 和 `cmd/render-manifest` 主路径。
|
||||
4. 增加 `gateway plugin test` 的 unit、manifest 和 upstream harness profile。
|
||||
5. 将 `source-build` 能力收敛为 `build --from-source`,保留兼容别名。
|
||||
6. 增加 runtime build/test adapter 接口,为 `go-plugin-process`、`sandbox-process` 和 WASM 实现预留扩展点。
|
||||
7. 增加 JSON report、conformance profile 和 CI golden 输出。
|
||||
|
||||
每一步结束时,现有 `inspect/validate/compat/source-validate/source-build` 不能回归。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 新建 `upstream-dialer` 模板后,不手写额外脚本即可 build/test/validate。
|
||||
- 新建 `protocol-proxy` 模板后,能跑通 Minecraft handshake/login smoke fixture。
|
||||
- `upstream-rewrite` 和 `mc-auth-proxy` 示例插件使用标准 CLI 生成 binary/source `.mcgp`。
|
||||
- 生成的 `.mcgp` 能通过现有上传和服务端校验。
|
||||
- manifest source 与 Go 代码不重复维护插件元数据。
|
||||
- Go plugin adapter 之外的 runtime 可以通过 adapter 注册进入同一套 `init/build/test` 命令。
|
||||
- CLI 失败输出能定位到字段、文件或 fixture,而不是只返回通用错误。
|
||||
88
docs/plugin-implementation-plan.md
Normal file
88
docs/plugin-implementation-plan.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# 插件系统阶段实现计划
|
||||
|
||||
本文以 [plugin-system-design.md](plugin-system-design.md) 作为最终目标设计文档,把插件系统拆分成多个可上线的实现阶段。每个阶段都必须在结束时保持 gateway 当前可用:可以启动、可以回滚、可以排障,且不会要求后续阶段补齐后才能恢复基本能力。
|
||||
|
||||
插件开发工具链作为跨阶段交付项单独设计,见 [plugin-development-toolchain-design.md](plugin-development-toolchain-design.md)。工具链主入口为 `gateway plugin init/build/test`,并需要从第一批 Go plugin 示例开始预留未来 runtime adapter。
|
||||
|
||||
## 拆分原则
|
||||
|
||||
- 以可用的纵向切片拆分,而不是按数据库、API、UI、SDK 等横向模块拆分。
|
||||
- 每个阶段都必须有明确的启用路径、失败回退路径和最小运维证据。
|
||||
- 默认运行路径保持保守:先稳定 `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 |
|
||||
| `gateway plugin init/build/test` 开发工具链 | 1-3,后续扩展 | 阶段 1/2 提供 Go plugin 模板和 harness,阶段 3 收敛 source/binary 打包;后续 runtime 通过 adapter 接入 |
|
||||
| Admin 页面基础管理闭环 | 4 | 上传、构建状态、加载、启用、禁用、删除、回滚 |
|
||||
| 配置 schema、配置快照、配置迁移入口 | 4 | 错误配置不切换 active artifact |
|
||||
| SecretStore、secret version、reload/rotation 基础 | 4 | secret 不在页面、日志、审计中明文展示 |
|
||||
| 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 应可在禁用插件系统配置下启动,并保留原有路由能力。
|
||||
108
docs/plugin-implementation-stages/phase-02-protocol-proxy-mvp.md
Normal file
108
docs/plugin-implementation-stages/phase-02-protocol-proxy-mvp.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# 阶段 2:Protocol Proxy MVP
|
||||
|
||||
## 目标
|
||||
|
||||
在阶段 1 的管理和生命周期基础上,让 `upstream.connect/v1` 支持 protocol-proxy mode。插件可以返回自管 `net.Conn`,gateway 将已读取的 initial handshake bytes 回放给该连接,并把后续客户端字节转发给插件 endpoint。
|
||||
|
||||
本阶段使 MC 正版/三方登录插件具备技术可行性:登录、身份映射、forwarding 和登录后的协议处理都由插件完成,gateway core 只负责连接交接和治理。
|
||||
|
||||
阶段 2 实现后,gateway core 不解析 login/encryption/session,不消费插件内部认证结果,也不根据玩家名、UUID、权限或 session 状态改变后续路由。protocol-proxy 插件接管连接后,Minecraft 登录业务完全属于插件;core 只保留 initial data replay、双向 copy、draining、force close 和低基数运行摘要。
|
||||
|
||||
## 可用性检查点
|
||||
|
||||
阶段结束时必须能做到:
|
||||
|
||||
- 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. 构建后执行 manifest 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,123 @@
|
||||
# 阶段 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 修复或禁用。
|
||||
|
||||
## 实现说明
|
||||
|
||||
- Route/status/middleware/provider/event subscriber 仍复用插件 `Gateway.Hook` 注册模型,新增 typed SDK 结构保持和 `upstream.connect/v1` 一致。
|
||||
- 官方 rule/policy 以内置官方插件 `official.rule-policy` 提供,管理员启用后通过插件配置完成 host rewrite、source CIDR allow/deny、simple rate limit、maintenance mode 和 upstream rewrite。
|
||||
- Admin auth provider 当前作为 provider registry 能力预留和展示,不进入 MC 连接路径,也不替代本地 admin break-glass 登录。
|
||||
- Route provider 失败时优先使用 provider cache,未命中时回退到 SQLite route snapshot。
|
||||
@@ -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。
|
||||
7892
docs/plugin-system-design.md
Normal file
7892
docs/plugin-system-design.md
Normal file
File diff suppressed because it is too large
Load Diff
243
docs/tcp-web-port-reuse-design.md
Normal file
243
docs/tcp-web-port-reuse-design.md
Normal file
@@ -0,0 +1,243 @@
|
||||
# TCP 端口复用 Web 服务设计
|
||||
|
||||
## 背景
|
||||
|
||||
当前 gateway 的 Minecraft TCP 入口和 WebSocket/HTTP 入口分别监听端口:
|
||||
|
||||
- `runTcp` 使用 `net.Listen("tcp", :tcp.port)` 接收原始 Minecraft TCP 连接。
|
||||
- `runWebSocket` 使用 `http.ListenAndServe(:websocket.port)` 接收 HTTP/WebSocket 连接。
|
||||
- `handleRequest` 会读取连接的第一段数据,并通过 Minecraft 握手包里的 host 路由到上游。
|
||||
|
||||
因此当前不能把 `tcp.port` 和 `websocket.port` 配成同一个端口;两个监听器会竞争同一个 TCP 地址,后启动的服务会失败。
|
||||
|
||||
## 目标
|
||||
|
||||
在同一个 TCP 监听端口上同时提供:
|
||||
|
||||
- Minecraft 原始 TCP 转发服务。
|
||||
- Web 服务,包括普通 HTTP 接口和 WebSocket upgrade。
|
||||
|
||||
入口连接根据首包内容自动分流:
|
||||
|
||||
- HTTP/WebSocket 首包进入 Web 服务。
|
||||
- 其他首包进入现有 Minecraft TCP 转发流程。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不在本次设计中实现代码。
|
||||
- 不改变 KCP、QUIC 的端口与协议行为。
|
||||
- 不实现 HTTPS/TLS 复用。HTTPS 需要在 gateway 内终止 TLS,或由外部代理终止 TLS 后再转发明文 HTTP。
|
||||
- 不改变现有 Minecraft host 路由规则。
|
||||
- 不改变现有上游协议选择规则,例如 `quic://`、`kcp://`、`haproxy://`。
|
||||
|
||||
## 配置设计
|
||||
|
||||
不新增复用开关,复用行为由端口配置自动决定:
|
||||
|
||||
```toml
|
||||
[tcp]
|
||||
enable = true
|
||||
port = 25565
|
||||
|
||||
[websocket]
|
||||
enable = true
|
||||
port = 25566
|
||||
path = "/"
|
||||
```
|
||||
|
||||
端口判定规则:
|
||||
|
||||
- `tcp.enable = true`、`websocket.enable = true` 且 `tcp.port == websocket.port` 时,自动进入端口复用模式。
|
||||
- `tcp.enable = true`、`websocket.enable = true` 且 `tcp.port != websocket.port` 时,保持现有独立监听模式。
|
||||
- 只有 `tcp.enable = true` 时,只启动 Minecraft TCP 服务。
|
||||
- 只有 `websocket.enable = true` 时,只启动 Web 服务。
|
||||
- 端口比较应使用默认值归一化后的结果,例如 `websocket.port = 0` 应先按当前默认值视为 `25566`。
|
||||
|
||||
自动复用模式下:
|
||||
|
||||
- Web 服务不再单独监听 `websocket.port`。
|
||||
- Web 服务挂载到 TCP listener 的 HTTP 分流分支。
|
||||
- 实际对外端口为 `tcp.port`。
|
||||
- `websocket.port` 与 `tcp.port` 相同,作为触发自动复用的配置表达。
|
||||
|
||||
## 总体架构
|
||||
|
||||
复用模式下只有一个 TCP listener:
|
||||
|
||||
```text
|
||||
net.Listen(:tcp.port)
|
||||
|
|
||||
v
|
||||
Accept
|
||||
|
|
||||
v
|
||||
读取首批字节
|
||||
|
|
||||
+-- HTTP/WebSocket
|
||||
| -> HTTP channel listener
|
||||
| -> http.Server.Serve
|
||||
|
|
||||
+-- Minecraft TCP
|
||||
-> 回放首批字节
|
||||
-> handleRequest
|
||||
-> mapToHost
|
||||
-> proxyConnections
|
||||
```
|
||||
|
||||
关键点:首包只用于判断协议,不能被消费掉。分流后必须把已经读取的字节重新接回连接流。
|
||||
|
||||
## 协议识别规则
|
||||
|
||||
HTTP/WebSocket 都以 HTTP 请求开始,因此只需要识别 HTTP 方法前缀。
|
||||
|
||||
建议识别以下前缀:
|
||||
|
||||
- `GET `
|
||||
- `POST `
|
||||
- `HEAD `
|
||||
- `PUT `
|
||||
- `PATCH `
|
||||
- `DELETE `
|
||||
- `OPTIONS `
|
||||
- `CONNECT `
|
||||
- `TRACE `
|
||||
|
||||
WebSocket upgrade 请求通常是 `GET /path HTTP/1.1`,会自然进入 HTTP 分支,再由现有 WebSocket handler 处理。
|
||||
|
||||
未匹配 HTTP 方法前缀的连接全部进入 Minecraft TCP 分支。
|
||||
|
||||
## 首包读取与回放
|
||||
|
||||
新增一个包装连接,例如 `replayConn`:
|
||||
|
||||
```go
|
||||
type replayConn struct {
|
||||
net.Conn
|
||||
reader io.Reader
|
||||
}
|
||||
```
|
||||
|
||||
创建时把已读取的首包和原始连接拼接:
|
||||
|
||||
```go
|
||||
reader := io.MultiReader(bytes.NewReader(peeked), conn)
|
||||
```
|
||||
|
||||
之后 `Read` 从 `reader` 读取,`Write`、`Close`、deadline、地址信息继续委托给底层 `net.Conn`。
|
||||
|
||||
这样 Minecraft 分支仍然可以使用现有 `handleRequest(replayConn)`,`mapToHost` 读到的内容和没有分流时一致。
|
||||
|
||||
## HTTP 接入方式
|
||||
|
||||
不建议手写 HTTP 解析。建议实现一个 channel listener:
|
||||
|
||||
```go
|
||||
type chanListener struct {
|
||||
conns chan net.Conn
|
||||
closed chan struct{}
|
||||
addr net.Addr
|
||||
}
|
||||
```
|
||||
|
||||
行为:
|
||||
|
||||
- 主 TCP accept 循环识别到 HTTP 后,把 `replayConn` 投递到 `chanListener.conns`。
|
||||
- `http.Server.Serve(chanListener)` 负责标准 HTTP/WebSocket 处理。
|
||||
- 关闭 gateway 时关闭 `chanListener`,让 HTTP server 退出。
|
||||
|
||||
优点:
|
||||
|
||||
- 继续使用 Go 标准库 HTTP server。
|
||||
- WebSocket upgrade 流程不需要重写。
|
||||
- 可以复用现有 `handleWebSocket`。
|
||||
|
||||
## 运行流程
|
||||
|
||||
自动复用模式启动流程:
|
||||
|
||||
1. 加载配置。
|
||||
2. 创建 Web handler 和 `http.Server`,但不调用 `ListenAndServe`。
|
||||
3. 创建 TCP listener。
|
||||
4. 启动 `http.Server.Serve(chanListener)`。
|
||||
5. TCP accept 循环接收所有连接。
|
||||
6. 每个连接设置 socket option。
|
||||
7. 设置短读超时读取首批字节。
|
||||
8. 根据首包分流到 Web 或 Minecraft。
|
||||
|
||||
非复用模式保持现有流程:
|
||||
|
||||
- TCP 服务继续由 `runTcp` 独立监听。
|
||||
- WebSocket 服务继续由 `runWebSocket` 独立监听。
|
||||
|
||||
## 超时与错误处理
|
||||
|
||||
首包读取需要短超时,避免空连接或慢连接长期占用 goroutine。
|
||||
|
||||
建议策略:
|
||||
|
||||
- 首包读取超时:关闭连接并记录 debug 或 warn 日志。
|
||||
- 首包为空:关闭连接。
|
||||
- HTTP 分支投递失败:关闭连接。
|
||||
- Minecraft 分支继续使用现有错误处理。
|
||||
|
||||
读超时只用于首包判断。分流完成后应清除 read deadline,避免影响长连接转发。
|
||||
|
||||
## 与现有热路径的关系
|
||||
|
||||
Minecraft TCP 分支应尽量保持现有转发路径:
|
||||
|
||||
- `handleRequest`
|
||||
- `mapToHost`
|
||||
- `proxyConnections`
|
||||
- `copyForward`
|
||||
|
||||
复用逻辑只出现在 listener 和首包分流层,不进入双向转发热路径。这样可以避免破坏已有 TCP 转发优化。
|
||||
|
||||
## 测试计划
|
||||
|
||||
单元测试:
|
||||
|
||||
- HTTP 方法前缀识别。
|
||||
- 非 HTTP 首包进入 Minecraft 分支。
|
||||
- `replayConn` 能先读出已窥探字节,再读底层连接后续字节。
|
||||
- 首包读取超时会关闭连接。
|
||||
- `chanListener` 的 `Accept`、`Close` 行为。
|
||||
|
||||
集成测试:
|
||||
|
||||
- 复用模式下,同一端口可以访问 HTTP 接口。
|
||||
- 复用模式下,同一端口可以完成 WebSocket upgrade。
|
||||
- 复用模式下,Minecraft 握手包仍能被 `protocol.GetMcHost` 正确解析。
|
||||
- 非复用模式下,现有 `tcp.port` 和 `websocket.port` 行为不变。
|
||||
- `tcp.port == websocket.port` 时自动复用,不应出现两个 listener 竞争同一端口。
|
||||
|
||||
性能验证:
|
||||
|
||||
- 对 Minecraft TCP 转发路径跑现有 benchmark,确认复用层没有影响已建立连接后的转发性能。
|
||||
- 至少覆盖 loopback TCP 场景,因为该项目的主要热路径是 TCP-to-TCP 转发。
|
||||
|
||||
## 风险与边界
|
||||
|
||||
- HTTP 识别只能覆盖明文 HTTP。TLS 握手首包不会匹配 HTTP 方法,会被送入 Minecraft 分支并失败。
|
||||
- 某些非 HTTP 协议如果首包刚好以 `GET ` 等方法前缀开头,会被误判为 Web 请求;对 Minecraft Java 握手来说风险很低。
|
||||
- 首包读取缓冲区不能太小,否则可能影响识别;但 HTTP 方法识别只需要很少字节。
|
||||
- 复用模式改变 listener 所有权,需要注意进程退出时 TCP listener、HTTP server、channel listener 的关闭顺序。
|
||||
|
||||
## 实施步骤
|
||||
|
||||
1. 增加端口默认值归一化和启动模式判定逻辑。
|
||||
2. 新增 HTTP 方法识别函数。
|
||||
3. 新增 `replayConn`。
|
||||
4. 新增 `chanListener`。
|
||||
5. 拆分 WebSocket handler 注册逻辑,让自动复用模式和独立监听模式都能使用同一套 handler。
|
||||
6. 新增自动复用模式的 TCP accept 分流入口。
|
||||
7. 增加配置校验,避免启动两个 listener 监听同一 TCP 地址。
|
||||
8. 补充单元测试、集成测试和 TCP benchmark 验证。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 当 `tcp.port == websocket.port` 且两个服务都启用时,只暴露该端口也能同时提供 Minecraft TCP 和 WebSocket/HTTP 服务。
|
||||
- Minecraft 客户端连接、host 路由、上游转发行为与复用前一致。
|
||||
- WebSocket 客户端通过同一端口能完成 upgrade 并进入现有 gateway 流程。
|
||||
- 端口不同或只启用单个服务时,现有配置和行为不变。
|
||||
- 测试覆盖首包识别、连接回放、HTTP 分流、Minecraft 分流和配置校验。
|
||||
12
examples/plugins/extension-ecosystem/README.md
Normal file
12
examples/plugins/extension-ecosystem/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Extension Ecosystem Example
|
||||
|
||||
This example demonstrates phase 7 extension points:
|
||||
|
||||
- `route.resolve/v1` returns `override`, `reject`, or `pass`.
|
||||
- `status.ping/v1` returns host-specific MOTD text.
|
||||
- `event.subscriber/v1` receives asynchronous plugin events.
|
||||
- `admin.auth.provider/v1` registers an unavailable external provider while preserving local admin fallback.
|
||||
|
||||
It is intended as a conformance fixture and source example for plugin authors.
|
||||
The source manifest is maintained as `manifest.yaml`; packaged `.mcgp` artifacts
|
||||
still contain canonical `manifest.json`.
|
||||
8
examples/plugins/extension-ecosystem/conformance.json
Normal file
8
examples/plugins/extension-ecosystem/conformance.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"route_decisions": ["override", "fallback", "reject", "pass"],
|
||||
"status_hosts": ["blue.example", "red.example"],
|
||||
"event_delivery": ["best_effort", "at_least_once", "dead_letter"],
|
||||
"provider_registry": ["singleton", "priority", "fallback", "admin.auth.provider/v1"],
|
||||
"default_route_must_survive_bad_rule_config": true,
|
||||
"local_admin_break_glass": true
|
||||
}
|
||||
51
examples/plugins/extension-ecosystem/manifest.yaml
Normal file
51
examples/plugins/extension-ecosystem/manifest.yaml
Normal file
@@ -0,0 +1,51 @@
|
||||
# examples/plugins/extension-ecosystem/manifest.yaml 是示例插件代码,用于演示托管插件接入方式。
|
||||
|
||||
# 人工维护的插件清单;构建插件包时会规范化为 manifest.json。
|
||||
schema_version: mc-gateway.plugin/v1
|
||||
id: extension-ecosystem-example
|
||||
name: Extension Ecosystem Example
|
||||
version: 0.1.0
|
||||
description: Example fixture for route, status, subscriber and provider extension points.
|
||||
artifact_type: binary
|
||||
runtime:
|
||||
type: go-plugin
|
||||
entry: plugin.so
|
||||
entry_symbol: Plugin
|
||||
api_version: plugin-api/v1
|
||||
sdk_module: github.com/tursom/mc-gateway/plugin/api
|
||||
sdk_module_version: v0.1.0
|
||||
go_version: go1.24.0
|
||||
go_os: linux
|
||||
go_arch: amd64
|
||||
extension_points:
|
||||
- type: provider
|
||||
key: route.resolve/v1
|
||||
- type: hook
|
||||
key: status.ping/v1
|
||||
- type: event
|
||||
key: event.subscriber/v1
|
||||
- type: provider
|
||||
key: admin.auth.provider/v1
|
||||
capabilities:
|
||||
extension_points:
|
||||
- route.resolve/v1
|
||||
- status.ping/v1
|
||||
- event.subscriber/v1
|
||||
- admin.auth.provider/v1
|
||||
route:
|
||||
cache_ttl_ms: 60000
|
||||
status:
|
||||
hosts:
|
||||
- blue.example
|
||||
- red.example
|
||||
event_subscriber:
|
||||
mode: at_least_once
|
||||
max_retry: 3
|
||||
providers:
|
||||
- type: admin.auth.provider/v1
|
||||
name: external-identity
|
||||
fallback: true
|
||||
runtime_limits:
|
||||
handler_timeout_ms: 1000
|
||||
config_schema:
|
||||
type: object
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user