commit 612d68f56f488ed635a4077305817391d341f238
Author: rose_cat707
Date: Mon Jun 15 04:46:59 2026 +0000
feat: Wormhole 内网穿透与反向代理网关
Go + Vue 管理台,SQLite 持久化;支持隧道/域名穿透、域名转发插件链、访客认证、
IP 规则、API Key、Docker 单镜像部署;配置通过 Web 系统设置管理,无需 YAML 文件。
Co-authored-by: Cursor
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..de03f20
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,11 @@
+nps/
+.git/
+bin/
+release/
+data/
+**/.DS_Store
+web/node_modules/
+web/dist/
+agent-transcripts/
+*.md
+!README.md
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..cc6e429
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,32 @@
+# ---> Go
+# If you prefer the allow list template instead of the deny list, see community template:
+# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
+#
+# Binaries for programs and plugins
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+
+# Test binary, built with `go test -c`
+*.test
+
+# Output of the go coverage tool, specifically when used with LiteIDE
+*.out
+
+# Dependency directories (remove the comment below to include it)
+# vendor/
+
+# Go workspace file
+go.work
+go.work.sum
+
+# env file
+.env
+
+# Wormhole
+bin/
+data/
+release/
+web/node_modules/
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..2144b88
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,75 @@
+# Wormhole 统一镜像(server / agent 同一二进制,启动命令区分角色)
+#
+# 构建:
+# docker build -t wormhole:latest .
+#
+# 服务端:
+# docker run -d --name wormhole \
+# -p 8529:8529 -p 8528:8528 -p 8081:8081 -p 8443:8443 \
+# -v wormhole-data:/app/data \
+# wormhole:latest
+#
+# Agent:
+# docker run -d --name wormhole-agent --network host \
+# wormhole:latest agent -server <服务端IP>:8528 -key
+
+# syntax=docker/dockerfile:1
+
+FROM --platform=$BUILDPLATFORM node:20-alpine AS web-builder
+
+WORKDIR /src/web
+COPY web/package.json web/package-lock.json ./
+RUN npm ci --registry=https://registry.npmmirror.com
+COPY web/ ./
+RUN npm run build
+
+FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS go-builder
+
+ARG TARGETARCH
+RUN apk add --no-cache gcc musl-dev curl tar \
+ && if [ "$TARGETARCH" = "amd64" ]; then \
+ curl -fsSL https://musl.cc/x86_64-linux-musl-cross.tgz -o /tmp/cross.tgz \
+ && tar -xzf /tmp/cross.tgz -C /usr/local \
+ && rm /tmp/cross.tgz; \
+ fi
+
+WORKDIR /src
+COPY go.mod go.sum go.env ./
+RUN --mount=type=cache,target=/go/pkg/mod \
+ GOPROXY=https://goproxy.cn,direct go mod download
+
+COPY cmd/ ./cmd/
+COPY internal/ ./internal/
+COPY web/embed.go ./web/
+COPY --from=web-builder /src/web/dist ./web/dist
+
+RUN --mount=type=cache,target=/go/pkg/mod \
+ --mount=type=cache,target=/root/.cache/go-build \
+ set -eu; \
+ export CGO_ENABLED=1 GOOS=linux GOARCH="$TARGETARCH" GOPROXY=https://goproxy.cn,direct; \
+ if [ "$TARGETARCH" = "amd64" ]; then \
+ export CC=/usr/local/x86_64-linux-musl-cross/bin/x86_64-linux-musl-gcc; \
+ fi; \
+ go build -ldflags="-w -s" -o /wormhole ./cmd/wormhole
+
+FROM alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc
+
+RUN apk add --no-cache ca-certificates tzdata \
+ && addgroup -S wormhole \
+ && adduser -S wormhole -G wormhole
+
+WORKDIR /app
+
+COPY --from=go-builder /wormhole /usr/local/bin/wormhole
+
+RUN mkdir -p /app/data \
+ && chown -R wormhole:wormhole /app
+
+USER wormhole
+
+VOLUME ["/app/data"]
+
+EXPOSE 8529 8528 8081 8443
+
+ENTRYPOINT ["/usr/local/bin/wormhole"]
+CMD ["server"]
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..941c1d1
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,38 @@
+# 运行参考(配置已迁移至 Web 管理台 → 系统设置,无需配置文件)
+
+.PHONY: tidy build build-all dev-web web install dev run run-agent docker-push-amd64 docker-push-multiarch
+
+export GOENV := $(CURDIR)/go.env
+export GOPROXY ?= https://goproxy.cn,direct
+export GOSUMDB ?= sum.golang.google.cn
+
+tidy:
+ go mod tidy
+
+web:
+ cd web && npm install --registry=https://registry.npmmirror.com && npm run build
+
+build: web
+ go build -o bin/wormhole ./cmd/wormhole
+
+build-all: build
+
+dev-web:
+ cd web && npm run dev
+
+run:
+ go run ./cmd/wormhole server
+
+run-agent:
+ go run ./cmd/wormhole agent -server 127.0.0.1:8528 -key $(KEY)
+
+install:
+ cd web && npm install --registry=https://registry.npmmirror.com
+
+docker-push-amd64:
+ chmod +x scripts/docker-build-push-amd64.sh
+ ./scripts/docker-build-push-amd64.sh
+
+docker-push-multiarch:
+ chmod +x scripts/docker-build-push-multiarch.sh
+ ./scripts/docker-build-push-multiarch.sh
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..2e850d0
--- /dev/null
+++ b/README.md
@@ -0,0 +1,75 @@
+# Wormhole
+
+内网穿透 + 反向代理网关(Go + Vue),SQLite 持久化 + 内存缓存。服务端与客户端为**同一二进制**,配置在 Web 管理台完成。
+
+## 功能
+
+- 客户端管理(Agent 注册、在线状态、verify_key)
+- 域名解析(HTTP/HTTPS Host 反代)
+- TCP/UDP 隧道
+- IP 安全:精确/CIDR/正则白黑名单、请求 IP 监控、一键拉黑
+- 统一登录 + 账号管理(admin/user)
+- 资源级访问验证(Basic / 表单 / 回调)
+- 仪表盘:流量与请求 IP 统计
+
+## 快速开始
+
+```bash
+make install
+make build
+
+# 启动服务端(无需配置文件,默认 admin/admin)
+make run
+# 或: ./bin/wormhole server
+
+# 在管理台「系统设置」中调整端口等参数
+
+# 启动客户端 Agent(仅需命令行参数)
+./bin/wormhole agent -server 127.0.0.1:8528 -key
+```
+
+管理界面:http://127.0.0.1:8529(首次启动默认端口,可在系统设置中修改)
+
+## Docker(单一镜像)
+
+```bash
+# 服务端
+docker run -d --name wormhole \
+ -p 8529:8529 -p 8528:8528 -p 8081:8081 -p 8443:8443 \
+ -v wormhole-data:/app/data \
+ registry.rc707blog.top/rose_cat707/wormhole:latest
+
+# Agent(同一镜像;务必加 --restart,服务端重启后 Agent 会自动重连)
+docker run -d --name wormhole-agent \
+ --restart=unless-stopped \
+ --network host \
+ registry.rc707blog.top/rose_cat707/wormhole:latest \
+ agent -server <服务端IP>:8528 -key
+```
+
+> **注意**:Agent 容器必须与 Server 分开部署。更新 Server 镜像时不要用同一 compose 重建 Agent;Agent 需 `--restart=unless-stopped`,否则收到 `SIGTERM` 后容器会保持 `Exited` 状态。
+
+## 命令
+
+| 命令 | 说明 |
+|------|------|
+| `wormhole server` | 启动服务端 |
+| `wormhole agent -server <地址> -key ` | 启动 Agent |
+
+数据与配置存储在 `./data/wormhole.db`(SQLite)。
+
+## 默认端口
+
+| 端口 | 用途 |
+|------|------|
+| 8529 | 管理 API + Web UI |
+| 8528 | Agent Bridge |
+| 8081 | HTTP 域名反代 |
+| 8443 | HTTPS 域名反代 |
+
+可在管理台 **系统设置** 中修改(监听端口变更需重启服务)。
+
+## 默认账号
+
+- 用户名:`admin`
+- 密码:`admin`
diff --git a/cmd/wormhole/main.go b/cmd/wormhole/main.go
new file mode 100644
index 0000000..9f0761e
--- /dev/null
+++ b/cmd/wormhole/main.go
@@ -0,0 +1,50 @@
+package main
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/wormhole/wormhole/internal/agent"
+ "github.com/wormhole/wormhole/internal/server"
+)
+
+func main() {
+ if len(os.Args) < 2 {
+ printUsage()
+ os.Exit(1)
+ }
+
+ cmd := os.Args[1]
+ args := os.Args[2:]
+
+ switch cmd {
+ case "server", "serve", "s":
+ server.Run(args)
+ case "agent", "client", "a":
+ agent.Run(args)
+ case "help", "-h", "--help":
+ printUsage()
+ default:
+ fmt.Fprintf(os.Stderr, "unknown command: %s\n\n", cmd)
+ printUsage()
+ os.Exit(1)
+ }
+}
+
+func printUsage() {
+ fmt.Fprintf(os.Stderr, `Wormhole — 内网穿透 / 反向代理(统一二进制)
+
+用法:
+ wormhole server 启动服务端(配置在 Web 管理台)
+ wormhole agent -server <地址> -key 启动客户端 Agent
+
+简写:
+ wormhole serve / wormhole s
+ wormhole client / wormhole a
+
+示例:
+ wormhole server
+ wormhole agent -server 127.0.0.1:8528 -key abc123
+
+`)
+}
diff --git a/go.env b/go.env
new file mode 100644
index 0000000..f089615
--- /dev/null
+++ b/go.env
@@ -0,0 +1,3 @@
+# 项目级 Go 工具链环境(go mod / go build 会读取)
+GOPROXY=https://goproxy.cn,direct
+GOSUMDB=sum.golang.google.cn
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..5edfe4c
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,52 @@
+module github.com/wormhole/wormhole
+
+go 1.24.0
+
+require (
+ github.com/gin-gonic/gin v1.10.0
+ github.com/golang-jwt/jwt/v5 v5.2.1
+ github.com/google/uuid v1.6.0
+ github.com/shirou/gopsutil/v3 v3.24.5
+ github.com/xtaci/smux v1.5.24
+ golang.org/x/crypto v0.28.0
+ golang.org/x/net v0.30.0
+ gopkg.in/yaml.v3 v3.0.1
+ gorm.io/driver/sqlite v1.5.7
+ gorm.io/gorm v1.25.12
+)
+
+require (
+ github.com/bytedance/sonic v1.11.6 // indirect
+ github.com/bytedance/sonic/loader v0.1.1 // indirect
+ github.com/cloudwego/base64x v0.1.4 // indirect
+ github.com/cloudwego/iasm v0.2.0 // indirect
+ github.com/gabriel-vasile/mimetype v1.4.3 // indirect
+ github.com/gin-contrib/sse v0.1.0 // indirect
+ github.com/go-ole/go-ole v1.2.6 // indirect
+ github.com/go-playground/locales v0.14.1 // indirect
+ github.com/go-playground/universal-translator v0.18.1 // indirect
+ github.com/go-playground/validator/v10 v10.20.0 // indirect
+ github.com/goccy/go-json v0.10.2 // indirect
+ github.com/jinzhu/inflection v1.0.0 // indirect
+ github.com/jinzhu/now v1.1.5 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/klauspost/cpuid/v2 v2.2.7 // indirect
+ github.com/leodido/go-urn v1.4.0 // indirect
+ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-sqlite3 v1.14.22 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/pelletier/go-toml/v2 v2.2.2 // indirect
+ github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
+ github.com/shoenig/go-m1cpu v0.1.6 // indirect
+ github.com/tklauser/go-sysconf v0.3.12 // indirect
+ github.com/tklauser/numcpus v0.6.1 // indirect
+ github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+ github.com/ugorji/go/codec v1.2.12 // indirect
+ github.com/yusufpapurcu/wmi v1.2.4 // indirect
+ golang.org/x/arch v0.8.0 // indirect
+ golang.org/x/sys v0.26.0 // indirect
+ golang.org/x/text v0.19.0 // indirect
+ google.golang.org/protobuf v1.34.1 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..2c0d5b7
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,127 @@
+github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
+github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
+github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
+github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
+github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
+github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
+github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
+github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
+github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
+github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
+github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
+github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
+github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
+github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
+github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
+github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
+github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
+github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
+github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
+github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
+github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
+github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
+github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
+github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
+github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
+github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
+github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
+github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
+github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
+github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
+github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
+github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
+github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
+github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
+github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
+github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
+github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
+github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
+github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
+github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
+github.com/xtaci/smux v1.5.24 h1:77emW9dtnOxxOQ5ltR+8BbsX1kzcOxQ5gB+aaV9hXOY=
+github.com/xtaci/smux v1.5.24/go.mod h1:OMlQbT5vcgl2gb49mFkYo6SMf+zP3rcjcwQz7ZU7IGY=
+github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
+github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
+golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
+golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
+golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
+golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
+golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
+golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
+golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
+golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
+golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
+golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
+google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
+gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
+gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
+gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
+nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
+rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
diff --git a/internal/agent/agent.go b/internal/agent/agent.go
new file mode 100644
index 0000000..291c226
--- /dev/null
+++ b/internal/agent/agent.go
@@ -0,0 +1,229 @@
+package agent
+
+import (
+ "crypto/tls"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "github.com/xtaci/smux"
+ "github.com/wormhole/wormhole/internal/bridge"
+ "github.com/wormhole/wormhole/internal/protocol"
+ "github.com/wormhole/wormhole/internal/version"
+)
+
+func Run(args []string) {
+ fs := flag.NewFlagSet("agent", flag.ExitOnError)
+ serverAddr := fs.String("server", "127.0.0.1:8528", "wormhole server bridge address")
+ verifyKey := fs.String("key", "", "client verify key")
+ useTLS := fs.Bool("tls", false, "use TLS for bridge connection")
+ fs.Parse(args)
+
+ if *verifyKey == "" {
+ log.Fatal("verify key required: -key")
+ }
+
+ sig := make(chan os.Signal, 1)
+ signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
+
+ backoff := time.Second
+ for {
+ select {
+ case <-sig:
+ log.Println("[agent] shutting down")
+ return
+ default:
+ }
+
+ shutdown, hadSession := runSession(*serverAddr, *verifyKey, *useTLS, sig)
+ if shutdown {
+ return
+ }
+ if hadSession {
+ backoff = time.Second
+ }
+
+ log.Printf("[agent] reconnecting in %v...", backoff)
+ select {
+ case <-sig:
+ log.Println("[agent] shutting down")
+ return
+ case <-time.After(backoff):
+ }
+ if backoff < 30*time.Second {
+ backoff *= 2
+ }
+ }
+}
+
+func dialBridge(addr string, useTLS bool) (net.Conn, error) {
+ var conn net.Conn
+ var err error
+ if useTLS {
+ conn, err = tls.Dial("tcp", addr, &tls.Config{
+ MinVersion: tls.VersionTLS12,
+ InsecureSkipVerify: true,
+ })
+ } else {
+ conn, err = net.Dial("tcp", addr)
+ }
+ if err != nil {
+ return nil, err
+ }
+ bridge.SetTCPKeepAlive(conn)
+ return conn, nil
+}
+
+// runSession connects and blocks until disconnect.
+// Returns shutdown=true when SIGINT/SIGTERM received; hadSession=true if registration succeeded.
+func runSession(serverAddr, verifyKey string, useTLS bool, sig chan os.Signal) (shutdown, hadSession bool) {
+ conn, err := dialBridge(serverAddr, useTLS)
+ if err != nil {
+ log.Printf("[agent] dial server: %v", err)
+ return false, false
+ }
+ defer conn.Close()
+
+ if err := protocol.WriteMessage(conn, protocol.MsgRegister, protocol.RegisterPayload{
+ VerifyKey: verifyKey,
+ Version: version.Version,
+ }); err != nil {
+ log.Printf("[agent] register: %v", err)
+ return false, false
+ }
+ msgType, body, err := protocol.ReadMessage(conn)
+ if err != nil {
+ log.Printf("[agent] register ack: %v", err)
+ return false, false
+ }
+ if msgType != protocol.MsgRegisterAck {
+ log.Printf("[agent] unexpected register response")
+ return false, false
+ }
+ var ack protocol.RegisterAckPayload
+ if len(body) > 0 {
+ if err := json.Unmarshal(body, &ack); err != nil {
+ log.Printf("[agent] parse ack: %v", err)
+ return false, false
+ }
+ }
+ if !ack.OK {
+ log.Printf("[agent] register failed: %s", ack.Message)
+ return false, false
+ }
+ log.Printf("[agent] registered as client %d", ack.ClientID)
+ hadSession = true
+
+ session, err := smux.Server(conn, bridge.SmuxConfig())
+ if err != nil {
+ log.Printf("[agent] smux: %v", err)
+ return false, hadSession
+ }
+ defer session.Close()
+
+ sessionDead := make(chan string, 1)
+ go func() {
+ reason := heartbeat(session)
+ sessionDead <- reason
+ }()
+
+ go func() {
+ for {
+ stream, err := session.AcceptStream()
+ if err != nil {
+ select {
+ case sessionDead <- "accept stream: " + err.Error():
+ default:
+ }
+ return
+ }
+ go handleStream(stream)
+ }
+ }()
+
+ select {
+ case <-sig:
+ return true, hadSession
+ case reason := <-sessionDead:
+ log.Printf("[agent] session closed (%s)", reason)
+ return false, hadSession
+ }
+}
+
+const heartbeatMaxFailures = 3
+
+func heartbeat(session *smux.Session) string {
+ ticker := time.NewTicker(bridge.HeartbeatInterval)
+ defer ticker.Stop()
+ failures := 0
+ for range ticker.C {
+ if err := pingOnce(session); err != nil {
+ failures++
+ log.Printf("[agent] heartbeat failed (%d/%d): %v", failures, heartbeatMaxFailures, err)
+ if failures >= heartbeatMaxFailures {
+ return "heartbeat timeout"
+ }
+ continue
+ }
+ failures = 0
+ }
+ return "heartbeat stopped"
+}
+
+func pingOnce(session *smux.Session) error {
+ stream, err := session.OpenStream()
+ if err != nil {
+ return err
+ }
+ defer stream.Close()
+ if err := protocol.WriteMessage(stream, protocol.MsgPing, nil); err != nil {
+ return err
+ }
+ msgType, _, err := protocol.ReadMessage(stream)
+ if err != nil {
+ return err
+ }
+ if msgType != protocol.MsgPong {
+ return fmt.Errorf("unexpected response type %d", msgType)
+ }
+ return nil
+}
+
+func handleStream(stream *smux.Stream) {
+ defer stream.Close()
+ msgType, body, err := protocol.ReadMessage(stream)
+ if err != nil {
+ return
+ }
+ switch msgType {
+ case protocol.MsgPing:
+ _ = protocol.WriteMessage(stream, protocol.MsgPong, nil)
+ return
+ case protocol.MsgOpenStream:
+ handleDataStream(stream, body)
+ }
+}
+
+func handleDataStream(stream *smux.Stream, body []byte) {
+ meta, err := protocol.ParseLinkMeta(body)
+ if err != nil {
+ return
+ }
+ target, err := net.Dial("tcp", meta.Target)
+ if err != nil {
+ return
+ }
+ defer target.Close()
+ _ = protocol.WriteMessage(stream, protocol.MsgStreamReady, nil)
+ go func() {
+ _, _ = io.Copy(target, stream)
+ }()
+ _, _ = io.Copy(stream, target)
+}
diff --git a/internal/api/domain_forwards.go b/internal/api/domain_forwards.go
new file mode 100644
index 0000000..b906261
--- /dev/null
+++ b/internal/api/domain_forwards.go
@@ -0,0 +1,144 @@
+package api
+
+import (
+ "net/http"
+ "strconv"
+
+ "github.com/gin-gonic/gin"
+ "github.com/wormhole/wormhole/internal/auth"
+ "github.com/wormhole/wormhole/internal/forwardplugin"
+ "github.com/wormhole/wormhole/internal/store"
+)
+
+func (s *Server) listDomainForwards(c *gin.Context) {
+ if !auth.IsAdmin(getClaims(c).Role) {
+ c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
+ return
+ }
+ p := pageParams(c)
+ items, total, err := s.store.ListDomainForwardsPaged(p)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
+}
+
+func (s *Server) createDomainForward(c *gin.Context) {
+ if !auth.IsAdmin(getClaims(c).Role) {
+ c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
+ return
+ }
+ var req store.DomainForward
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
+ return
+ }
+ if err := store.ValidateDomainForward(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ if err := s.store.RouteConflict(req.SourceHost, req.SourceLocation, 0, 0); err != nil {
+ c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
+ return
+ }
+ if err := s.store.CreateDomainForward(&req); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ s.cache.UpsertDomainForward(&req)
+ c.JSON(http.StatusOK, req)
+}
+
+func (s *Server) updateDomainForward(c *gin.Context) {
+ if !auth.IsAdmin(getClaims(c).Role) {
+ c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
+ return
+ }
+ id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ existing, err := s.store.GetDomainForwardByID(uint(id))
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
+ return
+ }
+ var req store.DomainForward
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
+ return
+ }
+ req.ID = existing.ID
+ if req.Socks5Pass == "" {
+ req.Socks5Pass = existing.Socks5Pass
+ }
+ if req.HTTPProxyPass == "" {
+ req.HTTPProxyPass = existing.HTTPProxyPass
+ }
+ if err := store.ValidateDomainForward(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ if err := s.store.RouteConflict(req.SourceHost, req.SourceLocation, uint(id), 0); err != nil {
+ c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
+ return
+ }
+ if err := s.store.UpdateDomainForward(&req); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ forwardplugin.InvalidateForwardCache(uint(id))
+ s.cache.UpsertDomainForward(&req)
+ c.JSON(http.StatusOK, req)
+}
+
+func (s *Server) deleteDomainForward(c *gin.Context) {
+ if !auth.IsAdmin(getClaims(c).Role) {
+ c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
+ return
+ }
+ id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ if _, err := s.store.GetDomainForwardByID(uint(id)); err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
+ return
+ }
+ if err := s.store.DeleteDomainForward(uint(id)); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ forwardplugin.InvalidateForwardCache(uint(id))
+ s.cache.DeleteDomainForward(uint(id))
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+}
+
+func (s *Server) pauseDomainForward(c *gin.Context) {
+ if !auth.IsAdmin(getClaims(c).Role) {
+ c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
+ return
+ }
+ id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ existing, err := s.store.GetDomainForwardByID(uint(id))
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
+ return
+ }
+ existing.Status = false
+ _ = s.store.UpdateDomainForward(existing)
+ s.cache.UpsertDomainForward(existing)
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+}
+
+func (s *Server) resumeDomainForward(c *gin.Context) {
+ if !auth.IsAdmin(getClaims(c).Role) {
+ c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
+ return
+ }
+ id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ existing, err := s.store.GetDomainForwardByID(uint(id))
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
+ return
+ }
+ existing.Status = true
+ _ = s.store.UpdateDomainForward(existing)
+ s.cache.UpsertDomainForward(existing)
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+}
diff --git a/internal/api/extra_handlers.go b/internal/api/extra_handlers.go
new file mode 100644
index 0000000..c33c526
--- /dev/null
+++ b/internal/api/extra_handlers.go
@@ -0,0 +1,174 @@
+package api
+
+import (
+ "net/http"
+ "strconv"
+
+ "github.com/gin-gonic/gin"
+ "github.com/wormhole/wormhole/internal/auth"
+ "github.com/wormhole/wormhole/internal/store"
+)
+
+func (s *Server) dashboardRankings(c *gin.Context) {
+ rankType := c.DefaultQuery("type", "tunnels")
+ page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
+ pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
+ if page < 1 {
+ page = 1
+ }
+ if pageSize < 1 {
+ pageSize = 50
+ }
+ if pageSize > 500 {
+ pageSize = 500
+ }
+ offset := (page - 1) * pageSize
+ q := c.Query("q")
+
+ switch rankType {
+ case "tunnels":
+ items, total, err := s.store.ListTunnelsByFlow(pageSize, offset)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
+ case "hosts":
+ items, total, err := s.store.ListHostsByFlow(pageSize, offset)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
+ case "ips":
+ items, total, err := s.store.ListAllRequestIPs(pageSize, offset, q)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
+ default:
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid type"})
+ }
+}
+
+func (s *Server) listAPIKeys(c *gin.Context) {
+ claims := getClaims(c)
+ keys, err := s.store.ListAPIKeys(claims.UserID, auth.IsAdmin(claims.Role))
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, keys)
+}
+
+func (s *Server) createAPIKey(c *gin.Context) {
+ claims := getClaims(c)
+ var req struct {
+ Name string `json:"name"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
+ return
+ }
+ key, plain, err := s.store.CreateAPIKey(req.Name, claims.UserID)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "key": key,
+ "api_key": plain,
+ "note": "请妥善保存 API Key,仅显示一次",
+ })
+}
+
+func (s *Server) deleteAPIKey(c *gin.Context) {
+ claims := getClaims(c)
+ id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ if err := s.store.DeleteAPIKey(uint(id), claims.UserID, auth.IsAdmin(claims.Role)); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+}
+
+func (s *Server) pauseTunnel(c *gin.Context) {
+ id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ claims := getClaims(c)
+ existing, err := s.store.GetTunnelByID(uint(id))
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
+ return
+ }
+ if err := store.ClientOwnerCheck(s.store, existing.ClientID, claims.UserID, auth.IsAdmin(claims.Role)); err != nil {
+ c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
+ return
+ }
+ _ = s.proxy.StopTunnel(uint(id))
+ existing.Status = false
+ existing.RunStatus = false
+ _ = s.store.UpdateTunnel(existing)
+ s.cache.UpsertTunnel(existing)
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+}
+
+func (s *Server) resumeTunnel(c *gin.Context) {
+ id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ claims := getClaims(c)
+ existing, err := s.store.GetTunnelByID(uint(id))
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
+ return
+ }
+ if err := store.ClientOwnerCheck(s.store, existing.ClientID, claims.UserID, auth.IsAdmin(claims.Role)); err != nil {
+ c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
+ return
+ }
+ existing.Status = true
+ _ = s.store.UpdateTunnel(existing)
+ s.cache.UpsertTunnel(existing)
+ _ = s.proxy.StartTunnel(uint(id))
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+}
+
+func (s *Server) pauseHost(c *gin.Context) {
+ id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ claims := getClaims(c)
+ existing, err := s.store.GetHostByID(uint(id))
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
+ return
+ }
+ if err := store.ClientOwnerCheck(s.store, existing.ClientID, claims.UserID, auth.IsAdmin(claims.Role)); err != nil {
+ c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
+ return
+ }
+ existing.Status = false
+ _ = s.store.UpdateHost(existing)
+ s.cache.UpsertHost(existing)
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+}
+
+func (s *Server) resumeHost(c *gin.Context) {
+ id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ claims := getClaims(c)
+ existing, err := s.store.GetHostByID(uint(id))
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
+ return
+ }
+ if err := store.ClientOwnerCheck(s.store, existing.ClientID, claims.UserID, auth.IsAdmin(claims.Role)); err != nil {
+ c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
+ return
+ }
+ existing.Status = true
+ _ = s.store.UpdateHost(existing)
+ s.cache.UpsertHost(existing)
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+}
+
+func (s *Server) openapiDoc(c *gin.Context) {
+ c.Header("Content-Type", "application/json")
+ c.String(http.StatusOK, openAPISpec)
+}
diff --git a/internal/api/forward_plugins.go b/internal/api/forward_plugins.go
new file mode 100644
index 0000000..f01f16b
--- /dev/null
+++ b/internal/api/forward_plugins.go
@@ -0,0 +1,17 @@
+package api
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/wormhole/wormhole/internal/auth"
+ "github.com/wormhole/wormhole/internal/forwardplugin"
+)
+
+func (s *Server) listForwardPluginTypes(c *gin.Context) {
+ if !auth.IsAdmin(getClaims(c).Role) {
+ c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"items": forwardplugin.ListPluginTypes()})
+}
diff --git a/internal/api/import_export.go b/internal/api/import_export.go
new file mode 100644
index 0000000..ec8acd4
--- /dev/null
+++ b/internal/api/import_export.go
@@ -0,0 +1,348 @@
+package api
+
+import (
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/wormhole/wormhole/internal/auth"
+ "github.com/wormhole/wormhole/internal/store"
+)
+
+type importResult struct {
+ Created int `json:"created"`
+ Updated int `json:"updated"`
+ Skipped int `json:"skipped"`
+ Failed int `json:"failed"`
+ Errors []string `json:"errors"`
+}
+
+type tunnelImportRequest struct {
+ Mode string `json:"mode"` // create, upsert, skip
+ Items []store.TunnelExportItem `json:"items"`
+}
+
+type hostImportRequest struct {
+ Mode string `json:"mode"`
+ Items []store.HostExportItem `json:"items"`
+}
+
+func (s *Server) exportTunnels(c *gin.Context) {
+ claims := getClaims(c)
+ clientID, _ := strconv.ParseUint(c.Query("client_id"), 10, 64)
+ tunnels, err := s.store.ListTunnels(uint(clientID), claims.UserID, auth.IsAdmin(claims.Role))
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ clientNames := tunnelClientNames(s.store, tunnels)
+ items := make([]store.TunnelExportItem, 0, len(tunnels))
+ for _, t := range tunnels {
+ items = append(items, store.TunnelToExportItem(t, clientNames[t.ClientID]))
+ }
+ doc := store.TunnelExportDoc{
+ Version: store.ExportVersion,
+ ExportedAt: time.Now().UTC(),
+ Items: items,
+ }
+ c.Header("Content-Disposition", `attachment; filename="wormhole-tunnels.json"`)
+ c.JSON(http.StatusOK, doc)
+}
+
+func (s *Server) importTunnels(c *gin.Context) {
+ claims := getClaims(c)
+ var req tunnelImportRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
+ return
+ }
+ if len(req.Items) == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "items is empty"})
+ return
+ }
+ mode := req.Mode
+ if mode == "" {
+ mode = "upsert"
+ }
+ result := importResult{}
+ for i, item := range req.Items {
+ clientID, err := store.ResolveImportClientID(s.store, item.ClientID, item.ClientName, claims.UserID, auth.IsAdmin(claims.Role))
+ if err != nil {
+ result.Failed++
+ result.Errors = append(result.Errors, formatImportError("tunnel", i+1, err))
+ continue
+ }
+ t := tunnelFromImportItem(item, clientID)
+ if err := normalizeTunnel(&t); err != nil {
+ result.Failed++
+ result.Errors = append(result.Errors, formatImportError("tunnel", i+1, err))
+ continue
+ }
+ existing, err := s.store.FindTunnelByPort(t.ListenPort)
+ switch mode {
+ case "create":
+ if err == nil {
+ result.Skipped++
+ continue
+ }
+ if err := s.store.CreateTunnel(&t); err != nil {
+ result.Failed++
+ result.Errors = append(result.Errors, formatImportError("tunnel", i+1, err))
+ continue
+ }
+ s.cache.UpsertTunnel(&t)
+ if t.Status {
+ _ = s.proxy.StartTunnel(t.ID)
+ }
+ result.Created++
+ case "skip":
+ if err == nil {
+ result.Skipped++
+ continue
+ }
+ if err := s.store.CreateTunnel(&t); err != nil {
+ result.Failed++
+ result.Errors = append(result.Errors, formatImportError("tunnel", i+1, err))
+ continue
+ }
+ s.cache.UpsertTunnel(&t)
+ if t.Status {
+ _ = s.proxy.StartTunnel(t.ID)
+ }
+ result.Created++
+ default: // upsert
+ if err != nil {
+ if err := s.store.CreateTunnel(&t); err != nil {
+ result.Failed++
+ result.Errors = append(result.Errors, formatImportError("tunnel", i+1, err))
+ continue
+ }
+ s.cache.UpsertTunnel(&t)
+ if t.Status {
+ _ = s.proxy.StartTunnel(t.ID)
+ }
+ result.Created++
+ continue
+ }
+ wasRunning := existing.RunStatus
+ if wasRunning {
+ _ = s.proxy.StopTunnel(existing.ID)
+ }
+ t.ID = existing.ID
+ t.ClientID = existing.ClientID
+ t.RunStatus = false
+ if err := s.store.UpdateTunnel(&t); err != nil {
+ result.Failed++
+ result.Errors = append(result.Errors, formatImportError("tunnel", i+1, err))
+ continue
+ }
+ s.cache.UpsertTunnel(&t)
+ if t.Status {
+ _ = s.proxy.StartTunnel(t.ID)
+ }
+ result.Updated++
+ }
+ }
+ c.JSON(http.StatusOK, result)
+}
+
+func (s *Server) exportHosts(c *gin.Context) {
+ claims := getClaims(c)
+ clientID, _ := strconv.ParseUint(c.Query("client_id"), 10, 64)
+ hosts, err := s.store.ListHosts(uint(clientID), claims.UserID, auth.IsAdmin(claims.Role))
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ clientNames := hostClientNames(s.store, hosts)
+ items := make([]store.HostExportItem, 0, len(hosts))
+ for _, h := range hosts {
+ items = append(items, store.HostToExportItem(h, clientNames[h.ClientID]))
+ }
+ doc := store.HostExportDoc{
+ Version: store.ExportVersion,
+ ExportedAt: time.Now().UTC(),
+ Items: items,
+ }
+ c.Header("Content-Disposition", `attachment; filename="wormhole-hosts.json"`)
+ c.JSON(http.StatusOK, doc)
+}
+
+func (s *Server) importHosts(c *gin.Context) {
+ claims := getClaims(c)
+ var req hostImportRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
+ return
+ }
+ if len(req.Items) == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "items is empty"})
+ return
+ }
+ mode := req.Mode
+ if mode == "" {
+ mode = "upsert"
+ }
+ result := importResult{}
+ for i, item := range req.Items {
+ clientID, err := store.ResolveImportClientID(s.store, item.ClientID, item.ClientName, claims.UserID, auth.IsAdmin(claims.Role))
+ if err != nil {
+ result.Failed++
+ result.Errors = append(result.Errors, formatImportError("host", i+1, err))
+ continue
+ }
+ h := hostFromImportItem(item, clientID)
+ if err := normalizeHost(&h); err != nil {
+ result.Failed++
+ result.Errors = append(result.Errors, formatImportError("host", i+1, err))
+ continue
+ }
+ existing, err := s.store.FindHostByRouteGlobal(h.Host, h.Location)
+ switch mode {
+ case "create":
+ if err == nil {
+ result.Skipped++
+ continue
+ }
+ if err := s.store.CreateHost(&h); err != nil {
+ result.Failed++
+ result.Errors = append(result.Errors, formatImportError("host", i+1, err))
+ continue
+ }
+ s.cache.UpsertHost(&h)
+ result.Created++
+ case "skip":
+ if err == nil {
+ result.Skipped++
+ continue
+ }
+ if err := s.store.CreateHost(&h); err != nil {
+ result.Failed++
+ result.Errors = append(result.Errors, formatImportError("host", i+1, err))
+ continue
+ }
+ s.cache.UpsertHost(&h)
+ result.Created++
+ default:
+ if err != nil {
+ if err := s.store.CreateHost(&h); err != nil {
+ result.Failed++
+ result.Errors = append(result.Errors, formatImportError("host", i+1, err))
+ continue
+ }
+ s.cache.UpsertHost(&h)
+ result.Created++
+ continue
+ }
+ h.ID = existing.ID
+ h.ClientID = existing.ClientID
+ if err := s.store.UpdateHost(&h); err != nil {
+ result.Failed++
+ result.Errors = append(result.Errors, formatImportError("host", i+1, err))
+ continue
+ }
+ s.cache.UpsertHost(&h)
+ result.Updated++
+ }
+ }
+ c.JSON(http.StatusOK, result)
+}
+
+func tunnelFromImportItem(item store.TunnelExportItem, clientID uint) store.Tunnel {
+ return store.Tunnel{
+ ClientID: clientID,
+ Name: item.Name,
+ Mode: item.Mode,
+ ListenIP: item.ListenIP,
+ ListenPort: item.ListenPort,
+ TargetAddr: item.TargetAddr,
+ IPForwardMode: item.IPForwardMode,
+ CustomHeaders: item.CustomHeaders,
+ Status: item.Status,
+ }
+}
+
+func hostFromImportItem(item store.HostExportItem, clientID uint) store.Host {
+ return store.Host{
+ ClientID: clientID,
+ Host: item.Host,
+ Location: item.Location,
+ Scheme: item.Scheme,
+ TargetAddr: item.TargetAddr,
+ AutoHTTPS: item.AutoHTTPS,
+ IPForwardMode: item.IPForwardMode,
+ CustomHeaders: item.CustomHeaders,
+ Status: item.Status,
+ }
+}
+
+func normalizeTunnel(t *store.Tunnel) error {
+ if t.ListenIP == "" {
+ t.ListenIP = "0.0.0.0"
+ }
+ if t.Mode == "" {
+ t.Mode = "tcp"
+ }
+ if t.ListenPort <= 0 || t.TargetAddr == "" {
+ return errImportField("listen_port and target_addr required")
+ }
+ if t.IPForwardMode == "" {
+ t.IPForwardMode = "replace"
+ }
+ return nil
+}
+
+func normalizeHost(h *store.Host) error {
+ if h.Host == "" || h.TargetAddr == "" {
+ return errImportField("host and target_addr required")
+ }
+ if h.Location == "" {
+ h.Location = "/"
+ }
+ if h.Scheme == "" {
+ h.Scheme = "all"
+ }
+ if h.IPForwardMode == "" {
+ h.IPForwardMode = "replace"
+ }
+ return nil
+}
+
+type importFieldError string
+
+func errImportField(msg string) error { return importFieldError(msg) }
+
+func (e importFieldError) Error() string { return string(e) }
+
+func formatImportError(kind string, index int, err error) string {
+ return kind + " #" + strconv.Itoa(index) + ": " + err.Error()
+}
+
+func tunnelClientNames(st *store.Store, tunnels []store.Tunnel) map[uint]string {
+ ids := make(map[uint]struct{})
+ for _, t := range tunnels {
+ ids[t.ClientID] = struct{}{}
+ }
+ names := make(map[uint]string, len(ids))
+ for id := range ids {
+ if c, err := st.GetClientByID(id); err == nil {
+ names[id] = c.Name
+ }
+ }
+ return names
+}
+
+func hostClientNames(st *store.Store, hosts []store.Host) map[uint]string {
+ ids := make(map[uint]struct{})
+ for _, h := range hosts {
+ ids[h.ClientID] = struct{}{}
+ }
+ names := make(map[uint]string, len(ids))
+ for id := range ids {
+ if c, err := st.GetClientByID(id); err == nil {
+ names[id] = c.Name
+ }
+ }
+ return names
+}
diff --git a/internal/api/middleware.go b/internal/api/middleware.go
new file mode 100644
index 0000000..99a0798
--- /dev/null
+++ b/internal/api/middleware.go
@@ -0,0 +1,56 @@
+package api
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/wormhole/wormhole/internal/auth"
+ "github.com/wormhole/wormhole/internal/store"
+)
+
+func (s *Server) authMiddleware() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if key := c.GetHeader("X-API-Key"); key != "" {
+ user, err := s.store.ValidateAPIKey(key)
+ if err != nil {
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid api key"})
+ return
+ }
+ c.Set("claims", &auth.Claims{
+ UserID: user.ID,
+ Username: user.Username,
+ Role: user.Role,
+ })
+ c.Set("auth_via", "apikey")
+ c.Next()
+ return
+ }
+
+ token := c.GetHeader("Authorization")
+ if len(token) > 7 && token[:7] == "Bearer " {
+ token = token[7:]
+ } else {
+ token = c.Query("token")
+ }
+ if token == "" {
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
+ return
+ }
+ claims, err := s.auth.ParseToken(token)
+ if err != nil {
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
+ return
+ }
+ c.Set("claims", claims)
+ c.Set("auth_via", "jwt")
+ c.Next()
+ }
+}
+
+func pageParams(c *gin.Context) store.PageParams {
+ return store.ParsePageQuery(
+ c.DefaultQuery("page", "1"),
+ c.DefaultQuery("page_size", "20"),
+ c.Query("q"),
+ )
+}
diff --git a/internal/api/openapi.go b/internal/api/openapi.go
new file mode 100644
index 0000000..8d7d9a3
--- /dev/null
+++ b/internal/api/openapi.go
@@ -0,0 +1,451 @@
+package api
+
+// OpenAPI 3.0 spec for Wormhole external API (authenticate with X-API-Key header).
+const openAPISpec = `{
+ "openapi": "3.0.3",
+ "info": {
+ "title": "Wormhole API",
+ "version": "1.0.0",
+ "description": "使用 X-API-Key 请求头或 Bearer JWT 访问。Base URL: http://host:8529/api。域名转发接口需管理员权限。"
+ },
+ "servers": [{ "url": "/api" }],
+ "components": {
+ "securitySchemes": {
+ "ApiKeyAuth": { "type": "apiKey", "in": "header", "name": "X-API-Key" },
+ "BearerAuth": { "type": "http", "scheme": "bearer", "bearerFormat": "JWT" }
+ },
+ "schemas": {
+ "Error": {
+ "type": "object",
+ "properties": {
+ "error": { "type": "string", "description": "错误信息" }
+ }
+ },
+ "Ok": {
+ "type": "object",
+ "properties": {
+ "ok": { "type": "boolean", "example": true }
+ }
+ },
+ "PagedDomainForwards": {
+ "type": "object",
+ "properties": {
+ "items": {
+ "type": "array",
+ "items": { "$ref": "#/components/schemas/DomainForward" }
+ },
+ "total": { "type": "integer", "description": "总条数" }
+ }
+ },
+ "DomainForward": {
+ "type": "object",
+ "description": "域名转发规则(服务端直连,无需 Agent)",
+ "properties": {
+ "id": { "type": "integer", "readOnly": true },
+ "source_host": { "type": "string", "description": "源域名,支持通配如 *.example.com", "example": "old.example.com" },
+ "source_location": { "type": "string", "default": "/", "description": "路径前缀", "example": "/" },
+ "source_scheme": { "type": "string", "enum": ["all", "http", "https"], "default": "all" },
+ "mode": { "type": "string", "enum": ["redirect", "proxy"], "description": "redirect=跳转,proxy=反向代理" },
+ "target_url": { "type": "string", "format": "uri", "description": "目标 URL,需含协议与主机", "example": "https://new.example.com" },
+ "redirect_mode": { "type": "string", "enum": ["preserve", "fixed"], "default": "preserve", "description": "跳转模式:保留路径或固定地址" },
+ "redirect_code": { "type": "integer", "enum": [301, 302], "default": 302 },
+ "proxy_type": {
+ "type": "string",
+ "enum": ["http", "http_proxy", "socks5"],
+ "default": "http",
+ "description": "http=直连,http_proxy=经 HTTP 代理出站,socks5=经 SOCKS5 出站"
+ },
+ "http_proxy_addr": { "type": "string", "description": "HTTP 代理地址,proxy_type=http_proxy 时必填", "example": "127.0.0.1:8080" },
+ "http_proxy_user": { "type": "string", "description": "HTTP 代理用户名(可选)" },
+ "socks5_addr": { "type": "string", "description": "SOCKS5 地址,proxy_type=socks5 时必填", "example": "127.0.0.1:1080" },
+ "socks5_user": { "type": "string", "description": "SOCKS5 用户名(可选)" },
+ "preserve_host": { "type": "boolean", "default": false, "description": "反代时是否将源域名作为 Host 发给上游" },
+ "plugins": {
+ "type": "array",
+ "description": "反代插件链,按数组顺序执行;redirect 模式忽略",
+ "items": { "$ref": "#/components/schemas/ForwardPluginEntry" }
+ },
+ "status": { "type": "boolean", "default": true },
+ "created_at": { "type": "string", "format": "date-time", "readOnly": true },
+ "updated_at": { "type": "string", "format": "date-time", "readOnly": true }
+ },
+ "required": ["source_host", "mode", "target_url"]
+ },
+ "DomainForwardInput": {
+ "allOf": [
+ { "$ref": "#/components/schemas/DomainForward" },
+ {
+ "type": "object",
+ "properties": {
+ "http_proxy_pass": { "type": "string", "writeOnly": true, "description": "HTTP 代理密码;更新时留空表示不修改" },
+ "socks5_pass": { "type": "string", "writeOnly": true, "description": "SOCKS5 密码;更新时留空表示不修改" }
+ }
+ }
+ ]
+ },
+ "ForwardPluginEntry": {
+ "type": "object",
+ "required": ["type"],
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["sub_filter", "inject_js", "cookie_domain", "response_cache"],
+ "description": "插件类型"
+ },
+ "enabled": { "type": "boolean", "default": true },
+ "config": {
+ "description": "插件配置,结构随 type 变化",
+ "oneOf": [
+ { "$ref": "#/components/schemas/SubFilterPluginConfig" },
+ { "$ref": "#/components/schemas/InjectJSPluginConfig" },
+ { "$ref": "#/components/schemas/CookieDomainPluginConfig" },
+ { "$ref": "#/components/schemas/ResponseCachePluginConfig" }
+ ]
+ }
+ }
+ },
+ "SubFilterPluginConfig": {
+ "type": "object",
+ "description": "type=sub_filter:按 MIME 对响应体做字符串替换",
+ "properties": {
+ "filters": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "type": "object",
+ "required": ["from"],
+ "properties": {
+ "from": { "type": "string" },
+ "to": { "type": "string" }
+ }
+ }
+ },
+ "types": {
+ "type": "array",
+ "items": { "type": "string" },
+ "default": ["text/html"],
+ "example": ["text/html", "text/css"]
+ }
+ },
+ "required": ["filters"]
+ },
+ "InjectJSPluginConfig": {
+ "type": "object",
+ "description": "type=inject_js:在 HTML
+