feat: Wormhole 内网穿透与反向代理网关

Go + Vue 管理台,SQLite 持久化;支持隧道/域名穿透、域名转发插件链、访客认证、
IP 规则、API Key、Docker 单镜像部署;配置通过 Web 系统设置管理,无需 YAML 文件。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-15 04:46:59 +00:00
committed by renjue
commit 612d68f56f
113 changed files with 17585 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
nps/
.git/
bin/
release/
data/
**/.DS_Store
web/node_modules/
web/dist/
agent-transcripts/
*.md
!README.md
+32
View File
@@ -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/
+75
View File
@@ -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 <verify_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"]
+38
View File
@@ -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
+75
View File
@@ -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 <verify_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 <verify_key>
```
> **注意**Agent 容器必须与 Server 分开部署。更新 Server 镜像时不要用同一 compose 重建 AgentAgent 需 `--restart=unless-stopped`,否则收到 `SIGTERM` 后容器会保持 `Exited` 状态。
## 命令
| 命令 | 说明 |
|------|------|
| `wormhole server` | 启动服务端 |
| `wormhole agent -server <地址> -key <verify_key>` | 启动 Agent |
数据与配置存储在 `./data/wormhole.db`SQLite)。
## 默认端口
| 端口 | 用途 |
|------|------|
| 8529 | 管理 API + Web UI |
| 8528 | Agent Bridge |
| 8081 | HTTP 域名反代 |
| 8443 | HTTPS 域名反代 |
可在管理台 **系统设置** 中修改(监听端口变更需重启服务)。
## 默认账号
- 用户名:`admin`
- 密码:`admin`
+50
View File
@@ -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 <verify_key> 启动客户端 Agent
简写:
wormhole serve / wormhole s
wormhole client / wormhole a
示例:
wormhole server
wormhole agent -server 127.0.0.1:8528 -key abc123
`)
}
+3
View File
@@ -0,0 +1,3 @@
# 项目级 Go 工具链环境(go mod / go build 会读取)
GOPROXY=https://goproxy.cn,direct
GOSUMDB=sum.golang.google.cn
+52
View File
@@ -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
)
+127
View File
@@ -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=
+229
View File
@@ -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)
}
+144
View File
@@ -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})
}
+174
View File
@@ -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)
}
+17
View File
@@ -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()})
}
+348
View File
@@ -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
}
+56
View File
@@ -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"),
)
}
+451
View File
@@ -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 </body> 前注入脚本",
"properties": {
"script": { "type": "string", "example": "<script></script>" }
},
"required": ["script"]
},
"CookieDomainPluginConfig": {
"type": "object",
"description": "type=cookie_domain:改写 Set-Cookie 的 Domain",
"properties": {
"from": { "type": "string", "example": ".old.com" },
"to": { "type": "string", "example": ".new.com" }
},
"required": ["from", "to"]
},
"ResponseCachePluginConfig": {
"type": "object",
"description": "type=response_cache:缓存上游原始 GET 响应(不含 Set-Cookie",
"properties": {
"max_mb": { "type": "integer", "minimum": 0, "description": "缓存容量 MB0 表示禁用", "example": 50 },
"ttl_sec": { "type": "integer", "minimum": 1, "description": "TTL 秒,max_mb>0 时默认 300", "example": 300 }
}
},
"ForwardPluginTypes": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": { "$ref": "#/components/schemas/ForwardPluginTypeMeta" }
}
}
},
"ForwardPluginTypeMeta": {
"type": "object",
"properties": {
"type": { "type": "string" },
"label": { "type": "string" },
"description": { "type": "string" },
"config_schema": { "type": "object", "additionalProperties": true },
"default_config": { "type": "object", "additionalProperties": true }
}
}
},
"responses": {
"BadRequest": {
"description": "请求参数无效",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
},
"Forbidden": {
"description": "权限不足(需管理员)",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
},
"NotFound": {
"description": "资源不存在",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
},
"Conflict": {
"description": "路由冲突",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
}
}
},
"security": [{ "ApiKeyAuth": [] }, { "BearerAuth": [] }],
"paths": {
"/clients": {
"get": {
"summary": "列出客户端及在线状态",
"responses": { "200": { "description": "OK" } }
}
},
"/tunnels": {
"get": {
"summary": "分页查询隧道",
"parameters": [
{ "name": "page", "in": "query", "schema": { "type": "integer" } },
{ "name": "page_size", "in": "query", "schema": { "type": "integer" } },
{ "name": "q", "in": "query", "schema": { "type": "string" } }
],
"responses": { "200": { "description": "OK" } }
},
"post": { "summary": "创建隧道", "responses": { "200": { "description": "OK" } } }
},
"/tunnels/{id}": {
"put": { "summary": "更新隧道", "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }], "responses": { "200": { "description": "OK" } } },
"delete": { "summary": "删除隧道", "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }], "responses": { "200": { "description": "OK" } } }
},
"/tunnels/{id}/start": { "post": { "summary": "启动隧道", "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }], "responses": { "200": { "description": "OK" } } } },
"/tunnels/{id}/stop": { "post": { "summary": "停止隧道", "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }], "responses": { "200": { "description": "OK" } } } },
"/tunnels/{id}/pause": { "post": { "summary": "暂停隧道", "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }], "responses": { "200": { "description": "OK" } } } },
"/tunnels/{id}/resume": { "post": { "summary": "恢复隧道", "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }], "responses": { "200": { "description": "OK" } } } },
"/hosts": {
"get": {
"summary": "分页查询域名",
"parameters": [
{ "name": "page", "in": "query", "schema": { "type": "integer" } },
{ "name": "page_size", "in": "query", "schema": { "type": "integer" } },
{ "name": "q", "in": "query", "schema": { "type": "string" } }
],
"responses": { "200": { "description": "OK" } }
},
"post": { "summary": "创建域名", "responses": { "200": { "description": "OK" } } }
},
"/hosts/{id}": {
"put": { "summary": "更新域名", "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }], "responses": { "200": { "description": "OK" } } },
"delete": { "summary": "删除域名", "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }], "responses": { "200": { "description": "OK" } } }
},
"/hosts/{id}/pause": { "post": { "summary": "暂停域名", "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }], "responses": { "200": { "description": "OK" } } } },
"/hosts/{id}/resume": { "post": { "summary": "恢复域名", "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }], "responses": { "200": { "description": "OK" } } } },
"/domain-forwards": {
"get": {
"summary": "分页查询域名转发",
"description": "需管理员权限",
"parameters": [
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
{ "name": "page_size", "in": "query", "schema": { "type": "integer", "default": 20 } },
{ "name": "q", "in": "query", "schema": { "type": "string" }, "description": "搜索源域名、目标 URL" }
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/PagedDomainForwards" }
}
}
},
"403": { "$ref": "#/components/responses/Forbidden" }
}
},
"post": {
"summary": "创建域名转发",
"description": "需管理员权限。proxy 模式下可配置 plugins 插件链。",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/DomainForwardInput" },
"examples": {
"redirect": {
"summary": "302 跳转",
"value": {
"source_host": "old.example.com",
"source_location": "/",
"mode": "redirect",
"target_url": "https://new.example.com",
"redirect_mode": "preserve",
"redirect_code": 302,
"status": true
}
},
"proxy_with_plugins": {
"summary": "反代 + 插件链 + HTTP 代理出站",
"value": {
"source_host": "proxy.example.com",
"source_location": "/",
"source_scheme": "all",
"mode": "proxy",
"target_url": "https://backend.internal",
"proxy_type": "http_proxy",
"http_proxy_addr": "127.0.0.1:8080",
"http_proxy_user": "user",
"http_proxy_pass": "secret",
"preserve_host": false,
"plugins": [
{
"type": "response_cache",
"enabled": true,
"config": { "max_mb": 50, "ttl_sec": 300 }
},
{
"type": "sub_filter",
"enabled": true,
"config": {
"filters": [{ "from": "old.internal", "to": "proxy.example.com" }],
"types": ["text/html"]
}
},
{
"type": "inject_js",
"enabled": true,
"config": { "script": "<script>console.log(1)</script>" }
},
{
"type": "cookie_domain",
"enabled": true,
"config": { "from": ".internal", "to": ".example.com" }
}
],
"status": true
}
}
}
}
}
},
"responses": {
"200": {
"description": "创建成功",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/DomainForward" }
}
}
},
"400": { "$ref": "#/components/responses/BadRequest" },
"403": { "$ref": "#/components/responses/Forbidden" },
"409": { "$ref": "#/components/responses/Conflict" }
}
}
},
"/domain-forwards/{id}": {
"put": {
"summary": "更新域名转发",
"description": "需管理员权限。http_proxy_pass / socks5_pass 留空时不修改原密码。",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/DomainForwardInput" }
}
}
},
"responses": {
"200": {
"description": "更新成功",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/DomainForward" }
}
}
},
"400": { "$ref": "#/components/responses/BadRequest" },
"403": { "$ref": "#/components/responses/Forbidden" },
"404": { "$ref": "#/components/responses/NotFound" },
"409": { "$ref": "#/components/responses/Conflict" }
}
},
"delete": {
"summary": "删除域名转发",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
],
"responses": {
"200": {
"description": "删除成功",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Ok" }
}
}
},
"403": { "$ref": "#/components/responses/Forbidden" },
"404": { "$ref": "#/components/responses/NotFound" }
}
}
},
"/domain-forwards/{id}/pause": {
"post": {
"summary": "暂停域名转发",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Ok" }
}
}
},
"403": { "$ref": "#/components/responses/Forbidden" },
"404": { "$ref": "#/components/responses/NotFound" }
}
}
},
"/domain-forwards/{id}/resume": {
"post": {
"summary": "恢复域名转发",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Ok" }
}
}
},
"403": { "$ref": "#/components/responses/Forbidden" },
"404": { "$ref": "#/components/responses/NotFound" }
}
}
},
"/forward-plugin-types": {
"get": {
"summary": "获取域名转发插件类型元数据",
"description": "返回可用插件的 type、label、config_schema、default_config,供前端或 API 客户端动态构建配置。",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ForwardPluginTypes" }
}
}
},
"403": { "$ref": "#/components/responses/Forbidden" }
}
}
}
}
}`
+947
View File
@@ -0,0 +1,947 @@
package api
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/wormhole/wormhole/internal/auth"
"github.com/wormhole/wormhole/internal/bridge"
"github.com/wormhole/wormhole/internal/cache"
"github.com/wormhole/wormhole/internal/config"
"github.com/wormhole/wormhole/internal/metrics"
"github.com/wormhole/wormhole/internal/proxy"
"github.com/wormhole/wormhole/internal/security"
"github.com/wormhole/wormhole/internal/store"
)
type Server struct {
cfg *config.Config
store *store.Store
cache *cache.Manager
auth *auth.Service
resAuth *auth.ResourceAuth
security *security.Service
bridge *bridge.Bridge
proxy *proxy.Manager
metrics *metrics.Collector
}
func NewServer(
cfg *config.Config,
st *store.Store,
c *cache.Manager,
authSvc *auth.Service,
resAuth *auth.ResourceAuth,
sec *security.Service,
b *bridge.Bridge,
pm *proxy.Manager,
mc *metrics.Collector,
) *Server {
return &Server{
cfg: cfg,
store: st,
cache: c,
auth: authSvc,
resAuth: resAuth,
security: sec,
bridge: b,
proxy: pm,
metrics: mc,
}
}
func (s *Server) Router() *gin.Engine {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery(), gin.Logger())
api := r.Group("/api")
{
api.GET("/openapi.json", s.openapiDoc)
api.POST("/auth/login", s.login)
authGroup := api.Group("")
authGroup.Use(s.authMiddleware())
{
authGroup.GET("/auth/me", s.me)
authGroup.PUT("/auth/me/password", s.changeMyPassword)
authGroup.GET("/dashboard", s.dashboard)
authGroup.GET("/dashboard/rankings", s.dashboardRankings)
authGroup.GET("/api-keys", s.listAPIKeys)
authGroup.POST("/api-keys", s.createAPIKey)
authGroup.DELETE("/api-keys/:id", s.deleteAPIKey)
authGroup.GET("/clients", s.listClients)
authGroup.POST("/clients", s.createClient)
authGroup.PUT("/clients/:id", s.updateClient)
authGroup.DELETE("/clients/:id", s.deleteClient)
authGroup.GET("/tunnels", s.listTunnels)
authGroup.POST("/tunnels", s.createTunnel)
authGroup.PUT("/tunnels/:id", s.updateTunnel)
authGroup.DELETE("/tunnels/:id", s.deleteTunnel)
authGroup.POST("/tunnels/:id/start", s.startTunnel)
authGroup.POST("/tunnels/:id/stop", s.stopTunnel)
authGroup.POST("/tunnels/:id/pause", s.pauseTunnel)
authGroup.POST("/tunnels/:id/resume", s.resumeTunnel)
authGroup.GET("/tunnels/export", s.exportTunnels)
authGroup.POST("/tunnels/import", s.importTunnels)
authGroup.GET("/hosts", s.listHosts)
authGroup.POST("/hosts", s.createHost)
authGroup.PUT("/hosts/:id", s.updateHost)
authGroup.DELETE("/hosts/:id", s.deleteHost)
authGroup.POST("/hosts/:id/pause", s.pauseHost)
authGroup.POST("/hosts/:id/resume", s.resumeHost)
authGroup.GET("/hosts/export", s.exportHosts)
authGroup.POST("/hosts/import", s.importHosts)
authGroup.GET("/domain-forwards", s.listDomainForwards)
authGroup.POST("/domain-forwards", s.createDomainForward)
authGroup.PUT("/domain-forwards/:id", s.updateDomainForward)
authGroup.DELETE("/domain-forwards/:id", s.deleteDomainForward)
authGroup.POST("/domain-forwards/:id/pause", s.pauseDomainForward)
authGroup.POST("/domain-forwards/:id/resume", s.resumeDomainForward)
authGroup.GET("/forward-plugin-types", s.listForwardPluginTypes)
authGroup.GET("/security/ip-rules", s.listIPRules)
authGroup.POST("/security/ip-rules", s.createIPRule)
authGroup.DELETE("/security/ip-rules/:id", s.deleteIPRule)
authGroup.GET("/security/request-ips", s.listRequestIPs)
authGroup.POST("/security/block-ip", s.blockIP)
authGroup.GET("/auth-policies", s.listAuthPolicies)
authGroup.POST("/auth-policies", s.createAuthPolicy)
authGroup.DELETE("/auth-policies/:id", s.deleteAuthPolicy)
authGroup.GET("/settings", s.getSettings)
authGroup.PUT("/settings", s.updateSettings)
authGroup.GET("/users", s.listUsers)
authGroup.GET("/users/:id/access-logs", s.listVisitorAccessLogs)
authGroup.POST("/users", s.createUser)
authGroup.PUT("/users/:id", s.updateUser)
authGroup.DELETE("/users/:id", s.deleteUser)
}
}
return r
}
func getClaims(c *gin.Context) *auth.Claims {
v, _ := c.Get("claims")
claims, _ := v.(*auth.Claims)
return claims
}
func (s *Server) login(c *gin.Context) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
return
}
ip := auth.DirectRemoteIP(c.Request)
token, user, err := s.auth.Login(s.store, ip, req.Username, req.Password)
if err != nil {
var blocked *auth.LoginBlockedError
if errors.As(err, &blocked) {
secs := int(blocked.RetryAfter.Seconds())
if secs < 1 {
secs = 1
}
c.Header("Retry-After", strconv.Itoa(secs))
c.JSON(http.StatusTooManyRequests, gin.H{
"error": "登录尝试次数过多,请稍后再试",
"retry_after": secs,
})
return
}
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"token": token,
"user": gin.H{
"id": user.ID,
"username": user.Username,
"role": user.Role,
},
})
}
func (s *Server) me(c *gin.Context) {
claims := getClaims(c)
c.JSON(http.StatusOK, gin.H{
"id": claims.UserID,
"username": claims.Username,
"role": claims.Role,
})
}
func (s *Server) changeMyPassword(c *gin.Context) {
claims := getClaims(c)
if claims.Role == "visitor" {
c.JSON(http.StatusForbidden, gin.H{"error": "访客账号无法修改管理密码"})
return
}
var req struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
return
}
if req.CurrentPassword == "" || req.NewPassword == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "请输入当前密码和新密码"})
return
}
if len(req.NewPassword) < 4 {
c.JSON(http.StatusBadRequest, gin.H{"error": "新密码至少 4 位"})
return
}
user, err := s.store.GetUserByID(claims.UserID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
if !store.CheckPassword(user.PasswordHash, req.CurrentPassword) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "当前密码不正确"})
return
}
hash, err := store.HashPassword(req.NewPassword)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
user.PasswordHash = hash
if err := s.store.UpdateUser(user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (s *Server) dashboard(c *gin.Context) {
data, err := s.metrics.Dashboard()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, data)
}
func (s *Server) listClients(c *gin.Context) {
claims := getClaims(c)
clients, err := s.store.ListClients(claims.UserID, auth.IsAdmin(claims.Role))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
type clientView struct {
store.Client
Online bool `json:"online"`
}
out := make([]clientView, 0, len(clients))
for _, cl := range clients {
out = append(out, clientView{
Client: cl,
Online: s.bridge.IsOnline(cl.ID),
})
}
c.JSON(http.StatusOK, out)
}
func (s *Server) createClient(c *gin.Context) {
claims := getClaims(c)
var req store.Client
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
return
}
req.OwnerUserID = claims.UserID
if auth.IsAdmin(claims.Role) && req.OwnerUserID == 0 {
req.OwnerUserID = claims.UserID
}
if err := s.store.CreateClient(&req); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
s.cache.UpsertClient(&req)
c.JSON(http.StatusOK, req)
}
func (s *Server) updateClient(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
claims := getClaims(c)
if err := store.ClientOwnerCheck(s.store, uint(id), claims.UserID, auth.IsAdmin(claims.Role)); err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
var req store.Client
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
return
}
existing, err := s.store.GetClientByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
req.ID = existing.ID
req.VerifyKey = existing.VerifyKey
req.OwnerUserID = existing.OwnerUserID
if err := s.store.UpdateClient(&req); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
s.cache.UpsertClient(&req)
c.JSON(http.StatusOK, req)
}
func (s *Server) deleteClient(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
claims := getClaims(c)
if err := store.ClientOwnerCheck(s.store, uint(id), claims.UserID, auth.IsAdmin(claims.Role)); err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
if err := s.store.DeleteClient(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
s.cache.DeleteClient(uint(id))
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (s *Server) listTunnels(c *gin.Context) {
claims := getClaims(c)
clientID, _ := strconv.ParseUint(c.Query("client_id"), 10, 64)
p := pageParams(c)
tunnels, total, err := s.store.ListTunnelsPaged(uint(clientID), claims.UserID, auth.IsAdmin(claims.Role), p)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
items := make([]tunnelView, 0, len(tunnels))
for _, t := range tunnels {
view, err := s.enrichTunnelView(t)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
items = append(items, view)
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
}
func (s *Server) createTunnel(c *gin.Context) {
claims := getClaims(c)
var req tunnelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
return
}
if err := store.ClientOwnerCheck(s.store, req.ClientID, claims.UserID, auth.IsAdmin(claims.Role)); err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
if req.Mode == "" {
req.Mode = "tcp"
}
req.ListenIP = "0.0.0.0"
if req.VisitorBindMode == "" {
req.VisitorBindMode = "all"
}
if req.VisitorTTLSeconds <= 0 {
req.VisitorTTLSeconds = 7200
}
if req.VisitorAuthEnabled && req.Mode == "udp" {
c.JSON(http.StatusBadRequest, gin.H{"error": "UDP 隧道不支持访客登录"})
return
}
inUse, err := s.store.TunnelPortInUse(req.ListenPort, 0)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if inUse {
c.JSON(http.StatusConflict, gin.H{"error": "监听端口已被占用"})
return
}
if err := s.store.CreateTunnel(&req.Tunnel); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := s.saveResourceVisitors("tunnel", req.ID, req.VisitorAuthEnabled, req.VisitorBindMode, req.VisitorIDs); err != nil {
_ = s.store.DeleteTunnel(req.ID)
writeVisitorSaveError(c, err)
return
}
s.cache.UpsertTunnel(&req.Tunnel)
if req.Status {
_ = s.proxy.StartTunnel(req.ID)
}
view, _ := s.enrichTunnelView(req.Tunnel)
c.JSON(http.StatusOK, view)
}
func (s *Server) updateTunnel(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
}
var req tunnelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
return
}
req.ID = existing.ID
req.ClientID = existing.ClientID
req.ListenIP = "0.0.0.0"
if req.VisitorBindMode == "" {
req.VisitorBindMode = "all"
}
if req.VisitorTTLSeconds <= 0 {
req.VisitorTTLSeconds = 7200
}
if req.VisitorAuthEnabled && req.Mode == "udp" {
c.JSON(http.StatusBadRequest, gin.H{"error": "UDP 隧道不支持访客登录"})
return
}
inUse, err := s.store.TunnelPortInUse(req.ListenPort, uint(id))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if inUse {
c.JSON(http.StatusConflict, gin.H{"error": "监听端口已被占用"})
return
}
if err := s.store.UpdateTunnel(&req.Tunnel); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := s.saveResourceVisitors("tunnel", req.ID, req.VisitorAuthEnabled, req.VisitorBindMode, req.VisitorIDs); err != nil {
writeVisitorSaveError(c, err)
return
}
wasRunning := existing.RunStatus
s.cache.UpsertTunnel(&req.Tunnel)
if wasRunning || (req.Status && req.VisitorAuthEnabled != existing.VisitorAuthEnabled) {
_ = s.proxy.StopTunnel(uint(id))
if req.Status {
_ = s.proxy.StartTunnel(uint(id))
}
}
view, _ := s.enrichTunnelView(req.Tunnel)
c.JSON(http.StatusOK, view)
}
func (s *Server) deleteTunnel(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))
if err := s.store.DeleteTunnel(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
s.cache.DeleteTunnel(uint(id))
s.cache.DeleteResourceVisitors("tunnel", uint(id))
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (s *Server) startTunnel(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
}
if err := s.proxy.StartTunnel(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (s *Server) stopTunnel(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
}
if err := s.proxy.StopTunnel(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (s *Server) listHosts(c *gin.Context) {
claims := getClaims(c)
clientID, _ := strconv.ParseUint(c.Query("client_id"), 10, 64)
p := pageParams(c)
hosts, total, err := s.store.ListHostsPaged(uint(clientID), claims.UserID, auth.IsAdmin(claims.Role), p)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
items := make([]hostView, 0, len(hosts))
for _, h := range hosts {
view, err := s.enrichHostView(h)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
items = append(items, view)
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
}
func (s *Server) createHost(c *gin.Context) {
claims := getClaims(c)
var req hostRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
return
}
if err := store.ClientOwnerCheck(s.store, req.ClientID, claims.UserID, auth.IsAdmin(claims.Role)); err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
if req.Location == "" {
req.Location = "/"
}
if req.Scheme == "" {
req.Scheme = "all"
}
if req.VisitorBindMode == "" {
req.VisitorBindMode = "all"
}
if req.VisitorTTLSeconds <= 0 {
req.VisitorTTLSeconds = 7200
}
if err := s.store.RouteConflict(req.Host.Host, req.Location, 0, 0); err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
if err := s.store.CreateHost(&req.Host); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := s.saveResourceVisitors("host", req.ID, req.VisitorAuthEnabled, req.VisitorBindMode, req.VisitorIDs); err != nil {
_ = s.store.DeleteHost(req.ID)
writeVisitorSaveError(c, err)
return
}
s.cache.UpsertHost(&req.Host)
view, _ := s.enrichHostView(req.Host)
c.JSON(http.StatusOK, view)
}
func (s *Server) updateHost(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
}
var req hostRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
return
}
req.ID = existing.ID
req.ClientID = existing.ClientID
if req.VisitorBindMode == "" {
req.VisitorBindMode = "all"
}
if req.VisitorTTLSeconds <= 0 {
req.VisitorTTLSeconds = 7200
}
if err := s.store.RouteConflict(req.Host.Host, req.Location, 0, uint(id)); err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
if err := s.store.UpdateHost(&req.Host); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := s.saveResourceVisitors("host", req.ID, req.VisitorAuthEnabled, req.VisitorBindMode, req.VisitorIDs); err != nil {
writeVisitorSaveError(c, err)
return
}
s.cache.UpsertHost(&req.Host)
view, _ := s.enrichHostView(req.Host)
c.JSON(http.StatusOK, view)
}
func (s *Server) deleteHost(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
}
if err := s.store.DeleteHost(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
s.cache.DeleteHost(uint(id))
s.cache.DeleteResourceVisitors("host", uint(id))
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (s *Server) listIPRules(c *gin.Context) {
scope := c.Query("scope")
resourceID, _ := strconv.ParseUint(c.Query("resource_id"), 10, 64)
rules, err := s.store.ListIPRules(scope, uint(resourceID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, rules)
}
func (s *Server) createIPRule(c *gin.Context) {
var req store.IPRule
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
return
}
if req.PatternKind == "" {
req.PatternKind = security.DetectPatternKind(req.Pattern)
}
if err := s.store.CreateIPRule(&req); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_ = s.cache.ReloadIPRules(s.store)
c.JSON(http.StatusOK, req)
}
func (s *Server) deleteIPRule(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := s.store.DeleteIPRule(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_ = s.cache.ReloadIPRules(s.store)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (s *Server) listRequestIPs(c *gin.Context) {
resourceType := c.Query("resource_type")
resourceID, _ := strconv.ParseUint(c.Query("resource_id"), 10, 64)
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
q := c.Query("q")
if resourceType == "" && resourceID == 0 {
items, total, err := s.store.ListAllRequestIPs(limit, 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})
return
}
items, total, err := s.store.ListRequestIPs(resourceType, uint(resourceID), limit, offset)
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) blockIP(c *gin.Context) {
var req struct {
IP string `json:"ip"`
Scope string `json:"scope"`
ResourceID uint `json:"resource_id"`
Remark string `json:"remark"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
return
}
if req.Scope == "" {
req.Scope = "global"
}
if err := s.security.BlockIP(req.IP, req.Scope, req.ResourceID, req.Remark); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (s *Server) listAuthPolicies(c *gin.Context) {
resourceType := c.Query("resource_type")
resourceID, _ := strconv.ParseUint(c.Query("resource_id"), 10, 64)
policies, err := s.store.ListAuthPolicies(resourceType, uint(resourceID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, policies)
}
func (s *Server) createAuthPolicy(c *gin.Context) {
var req store.AuthPolicy
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
return
}
if err := s.resAuth.CreatePolicy(s.store, &req); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_ = s.cache.ReloadAll(s.store)
c.JSON(http.StatusOK, req)
}
func (s *Server) deleteAuthPolicy(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := s.store.DeleteAuthPolicy(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_ = s.cache.ReloadAll(s.store)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (s *Server) listUsers(c *gin.Context) {
claims := getClaims(c)
if !auth.IsAdmin(claims.Role) {
c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
return
}
users, err := s.store.ListVisitors()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, users)
}
func (s *Server) listVisitorAccessLogs(c *gin.Context) {
claims := getClaims(c)
if !auth.IsAdmin(claims.Role) {
c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
return
}
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
user, err := s.store.GetUserByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if user.Role != "visitor" {
c.JSON(http.StatusBadRequest, gin.H{"error": "仅可查看访客访问记录"})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "200"))
logs, err := s.store.ListVisitorAccessLogs(uint(id), limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
items := make([]gin.H, 0, len(logs))
for _, log := range logs {
items = append(items, gin.H{
"id": log.ID,
"user_id": log.UserID,
"resource_type": log.ResourceType,
"resource_id": log.ResourceID,
"resource_label": visitorResourceLabel(s.store, log.ResourceType, log.ResourceID),
"action": log.Action,
"method": log.Method,
"path": log.Path,
"ip": log.IP,
"user_agent": log.UserAgent,
"created_at": log.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
}
func visitorResourceLabel(st *store.Store, resourceType string, resourceID uint) string {
switch resourceType {
case "host":
h, err := st.GetHostByID(resourceID)
if err != nil {
return ""
}
label := h.Host
if h.Location != "" && h.Location != "/" {
label += h.Location
}
return label
case "tunnel":
t, err := st.GetTunnelByID(resourceID)
if err != nil {
return ""
}
if t.Name != "" {
return t.Name + " :" + strconv.Itoa(t.ListenPort)
}
return ":" + strconv.Itoa(t.ListenPort)
default:
return ""
}
}
func (s *Server) createUser(c *gin.Context) {
claims := getClaims(c)
if !auth.IsAdmin(claims.Role) {
c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
Role string `json:"role"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
return
}
hash, err := store.HashPassword(req.Password)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if req.Role == "" {
req.Role = "visitor"
}
if req.Role != "visitor" {
c.JSON(http.StatusBadRequest, gin.H{"error": "仅支持创建访客用户"})
return
}
user := store.User{
Username: req.Username,
PasswordHash: hash,
Role: req.Role,
Status: true,
}
if err := s.store.CreateUser(&user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, user)
}
func (s *Server) updateUser(c *gin.Context) {
claims := getClaims(c)
if !auth.IsAdmin(claims.Role) {
c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
return
}
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
user, err := s.store.GetUserByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if user.Role != "visitor" {
c.JSON(http.StatusBadRequest, gin.H{"error": "仅可编辑访客用户"})
return
}
var req struct {
Password string `json:"password"`
Role string `json:"role"`
Status *bool `json:"status"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
return
}
if req.Password != "" {
hash, err := store.HashPassword(req.Password)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
user.PasswordHash = hash
}
if req.Role != "" {
if req.Role != "visitor" {
c.JSON(http.StatusBadRequest, gin.H{"error": "仅支持访客用户"})
return
}
user.Role = req.Role
}
if req.Status != nil {
user.Status = *req.Status
}
if err := s.store.UpdateUser(user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, user)
}
func (s *Server) deleteUser(c *gin.Context) {
claims := getClaims(c)
if !auth.IsAdmin(claims.Role) {
c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
return
}
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
user, err := s.store.GetUserByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if user.Role != "visitor" {
c.JSON(http.StatusBadRequest, gin.H{"error": "仅可删除访客用户"})
return
}
if err := s.store.DeleteUser(uint(id), claims.UserID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+54
View File
@@ -0,0 +1,54 @@
package api
import (
"net/http"
"reflect"
"github.com/gin-gonic/gin"
"github.com/wormhole/wormhole/internal/auth"
"github.com/wormhole/wormhole/internal/config"
)
func (s *Server) getSettings(c *gin.Context) {
claims := getClaims(c)
if !auth.IsAdmin(claims.Role) {
c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
return
}
dto := s.cfg.ToDTO()
c.JSON(http.StatusOK, gin.H{
"settings": dto,
"note": "修改监听端口后需重启 wormhole server 生效",
})
}
func (s *Server) updateSettings(c *gin.Context) {
claims := getClaims(c)
if !auth.IsAdmin(claims.Role) {
c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
return
}
var req config.SettingsDTO
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
return
}
oldServer := s.cfg.Server
newCfg := config.SettingsFromDTO(req, s.cfg)
if err := s.store.SaveConfig(newCfg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
*s.cfg = *newCfg
s.auth.ReloadAuth(&s.cfg.Auth)
s.resAuth.ReloadAuth(&s.cfg.Auth)
restartRequired := !reflect.DeepEqual(oldServer, s.cfg.Server) ||
oldServer.TLSCertFile != s.cfg.Server.TLSCertFile ||
oldServer.TLSKeyFile != s.cfg.Server.TLSKeyFile
c.JSON(http.StatusOK, gin.H{
"settings": s.cfg.ToDTO(),
"restart_required": restartRequired,
})
}
+89
View File
@@ -0,0 +1,89 @@
package api
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/wormhole/wormhole/internal/store"
)
type hostRequest struct {
store.Host
VisitorIDs []uint `json:"visitor_ids"`
}
type tunnelRequest struct {
store.Tunnel
VisitorIDs []uint `json:"visitor_ids"`
}
type hostView struct {
store.Host
VisitorIDs []uint `json:"visitor_ids"`
}
type tunnelView struct {
store.Tunnel
VisitorIDs []uint `json:"visitor_ids"`
}
func (s *Server) enrichHostView(h store.Host) (hostView, error) {
ids, err := s.store.ListResourceVisitorIDs("host", h.ID)
if err != nil {
return hostView{}, err
}
return hostView{Host: h, VisitorIDs: ids}, nil
}
func (s *Server) enrichTunnelView(t store.Tunnel) (tunnelView, error) {
ids, err := s.store.ListResourceVisitorIDs("tunnel", t.ID)
if err != nil {
return tunnelView{}, err
}
return tunnelView{Tunnel: t, VisitorIDs: ids}, nil
}
func (s *Server) saveResourceVisitors(resourceType string, resourceID uint, enabled bool, bindMode string, visitorIDs []uint) error {
if !enabled {
_ = s.store.SetResourceVisitors(resourceType, resourceID, nil)
s.cache.SetResourceVisitors(resourceType, resourceID, nil)
return nil
}
if bindMode == "" {
bindMode = "all"
}
if bindMode == "selected" {
if len(visitorIDs) == 0 {
return fmt.Errorf("请至少选择一个访客用户")
}
if err := s.validateVisitorIDs(visitorIDs); err != nil {
return err
}
if err := s.store.SetResourceVisitors(resourceType, resourceID, visitorIDs); err != nil {
return err
}
s.cache.SetResourceVisitors(resourceType, resourceID, visitorIDs)
return nil
}
_ = s.store.SetResourceVisitors(resourceType, resourceID, nil)
s.cache.SetResourceVisitors(resourceType, resourceID, nil)
return nil
}
func (s *Server) validateVisitorIDs(ids []uint) error {
for _, id := range ids {
user, err := s.store.GetUserByID(id)
if err != nil {
return fmt.Errorf("访客用户 #%d 不存在", id)
}
if user.Role != "visitor" {
return fmt.Errorf("用户 %s 不是访客账号", user.Username)
}
}
return nil
}
func writeVisitorSaveError(c *gin.Context, err error) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
+55
View File
@@ -0,0 +1,55 @@
package auth
import (
"net"
"net/http"
"strings"
)
// DirectRemoteIP returns the direct TCP peer address, ignoring X-Forwarded-For.
// Use for security decisions (login rate limit, IP rules, resource auth).
func DirectRemoteIP(r *http.Request) string {
return HostOnly(r.RemoteAddr)
}
// AddrHost extracts the host portion from a net.Addr string (host:port or [host]:port).
func AddrHost(addr net.Addr) string {
if addr == nil {
return ""
}
return HostOnly(addr.String())
}
// HostOnly strips the port from an address string.
func HostOnly(addr string) string {
if addr == "" {
return ""
}
host, _, err := net.SplitHostPort(addr)
if err != nil {
return strings.Trim(addr, "[]")
}
return host
}
// ExtractRemoteIP returns the client IP, honoring X-Forwarded-For when present.
// Use only when forwarding visitor IP to upstream backends behind a trusted proxy.
func ExtractRemoteIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
return strings.TrimSpace(parts[0])
}
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return strings.TrimSpace(xri)
}
return DirectRemoteIP(r)
}
// SafeRedirectPath validates a post-login redirect path (relative only).
func SafeRedirectPath(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" || !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") {
return "/"
}
return raw
}
+118
View File
@@ -0,0 +1,118 @@
package auth
import (
"errors"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/wormhole/wormhole/internal/config"
"github.com/wormhole/wormhole/internal/store"
)
type Claims struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
type Service struct {
mu sync.RWMutex
cfg *config.AuthConfig
limiter *LoginLimiter
}
func NewService(cfg *config.AuthConfig) *Service {
return &Service{
cfg: cfg,
limiter: NewLoginLimiter(cfg),
}
}
func (s *Service) ReloadAuth(cfg *config.AuthConfig) {
s.mu.Lock()
defer s.mu.Unlock()
*s.cfg = *cfg
s.limiter = NewLoginLimiter(cfg)
}
func (s *Service) Login(st *store.Store, ip, username, password string) (string, *store.User, error) {
s.mu.RLock()
limiter := s.limiter
s.mu.RUnlock()
if retry, blocked := limiter.IsBlocked(ip, username); blocked {
return "", nil, &LoginBlockedError{RetryAfter: retry}
}
user, err := st.GetUserByUsername(username)
if err != nil {
limiter.RecordFailure(ip, username)
time.Sleep(300 * time.Millisecond)
return "", nil, errors.New("invalid credentials")
}
if !user.Status {
limiter.RecordFailure(ip, username)
time.Sleep(300 * time.Millisecond)
return "", nil, errors.New("invalid credentials")
}
if !store.CheckPassword(user.PasswordHash, password) {
limiter.RecordFailure(ip, username)
time.Sleep(300 * time.Millisecond)
return "", nil, errors.New("invalid credentials")
}
if user.Role == "visitor" {
limiter.RecordFailure(ip, username)
time.Sleep(300 * time.Millisecond)
return "", nil, errors.New("访客账号无法登录管理后台")
}
limiter.Reset(ip, username)
token, err := s.GenerateToken(user)
if err != nil {
return "", nil, err
}
return token, user, nil
}
func (s *Service) GenerateToken(user *store.User) (string, error) {
s.mu.RLock()
cfg := s.cfg
s.mu.RUnlock()
claims := Claims{
UserID: user.ID,
Username: user.Username,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(cfg.TokenTTL)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(cfg.JWTSecret))
}
func (s *Service) ParseToken(tokenStr string) (*Claims, error) {
s.mu.RLock()
secret := s.cfg.JWTSecret
s.mu.RUnlock()
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
return []byte(secret), nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}
func IsAdmin(role string) bool {
return role == "admin"
}
func IsVisitor(role string) bool {
return role == "visitor"
}
+123
View File
@@ -0,0 +1,123 @@
package auth
import (
"sync"
"time"
"github.com/wormhole/wormhole/internal/config"
)
type LoginBlockedError struct {
RetryAfter time.Duration
}
func (e *LoginBlockedError) Error() string {
return "too many login attempts"
}
type loginRecord struct {
failures int
windowStart time.Time
lockedUntil time.Time
}
type LoginLimiter struct {
mu sync.Mutex
maxAttempts int
window time.Duration
lockout time.Duration
records map[string]*loginRecord
}
func NewLoginLimiter(cfg *config.AuthConfig) *LoginLimiter {
if cfg.LoginMaxAttempts <= 0 {
return nil
}
max := cfg.LoginMaxAttempts
window := cfg.LoginWindow
lockout := cfg.LoginLockout
if window <= 0 {
window = 5 * time.Minute
}
if lockout <= 0 {
lockout = 15 * time.Minute
}
return &LoginLimiter{
maxAttempts: max,
window: window,
lockout: lockout,
records: make(map[string]*loginRecord),
}
}
func (l *LoginLimiter) keys(ip, username string) []string {
keys := []string{"ip:" + ip}
if username != "" {
keys = append(keys, "user:"+username)
}
return keys
}
func (l *LoginLimiter) IsBlocked(ip, username string) (time.Duration, bool) {
if l == nil {
return 0, false
}
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
var maxRetry time.Duration
for _, key := range l.keys(ip, username) {
rec := l.records[key]
if rec == nil {
continue
}
if now.Before(rec.lockedUntil) {
retry := time.Until(rec.lockedUntil)
if retry > maxRetry {
maxRetry = retry
}
}
}
return maxRetry, maxRetry > 0
}
func (l *LoginLimiter) RecordFailure(ip, username string) {
if l == nil {
return
}
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
for _, key := range l.keys(ip, username) {
rec := l.records[key]
if rec == nil {
rec = &loginRecord{windowStart: now}
l.records[key] = rec
}
if now.Before(rec.lockedUntil) {
continue
}
if now.Sub(rec.windowStart) > l.window {
rec.failures = 0
rec.windowStart = now
}
rec.failures++
if rec.failures >= l.maxAttempts {
rec.lockedUntil = now.Add(l.lockout)
rec.failures = 0
}
}
}
func (l *LoginLimiter) Reset(ip, username string) {
if l == nil {
return
}
l.mu.Lock()
defer l.mu.Unlock()
for _, key := range l.keys(ip, username) {
delete(l.records, key)
}
}
+59
View File
@@ -0,0 +1,59 @@
package auth
import (
"errors"
"testing"
"time"
"github.com/wormhole/wormhole/internal/config"
)
func TestLoginLimiterBlocksAfterMaxAttempts(t *testing.T) {
l := NewLoginLimiter(&config.AuthConfig{
LoginMaxAttempts: 3,
LoginWindow: time.Minute,
LoginLockout: 10 * time.Minute,
})
ip := "192.168.1.1"
user := "admin"
for i := 0; i < 3; i++ {
if _, blocked := l.IsBlocked(ip, user); blocked {
t.Fatalf("unexpected block at attempt %d", i+1)
}
l.RecordFailure(ip, user)
}
retry, blocked := l.IsBlocked(ip, user)
if !blocked || retry <= 0 {
t.Fatalf("expected block after max attempts, retry=%v blocked=%v", retry, blocked)
}
}
func TestLoginLimiterResetClearsBlock(t *testing.T) {
l := NewLoginLimiter(&config.AuthConfig{
LoginMaxAttempts: 2,
LoginWindow: time.Minute,
LoginLockout: time.Minute,
})
ip := "10.0.0.1"
l.RecordFailure(ip, "u1")
l.RecordFailure(ip, "u1")
if _, blocked := l.IsBlocked(ip, "u1"); !blocked {
t.Fatal("expected blocked")
}
l.Reset(ip, "u1")
if _, blocked := l.IsBlocked(ip, "u1"); blocked {
t.Fatal("expected unblocked after reset")
}
}
func TestLoginBlockedError(t *testing.T) {
err := &LoginBlockedError{RetryAfter: 30 * time.Second}
if !errors.As(err, new(*LoginBlockedError)) {
t.Fatal("LoginBlockedError should be detectable via errors.As")
}
}
+180
View File
@@ -0,0 +1,180 @@
package auth
import (
"crypto/subtle"
"fmt"
"net"
"net/http"
"sync"
"time"
"github.com/wormhole/wormhole/internal/cache"
"github.com/wormhole/wormhole/internal/config"
"github.com/wormhole/wormhole/internal/store"
)
const accessCookieName = "wh_access"
type ResourceAuth struct {
cache *cache.Manager
store *store.Store
jwtSecret string
limiter *LoginLimiter
mu sync.RWMutex
grants map[string]time.Time
}
func NewResourceAuth(c *cache.Manager, st *store.Store, authCfg *config.AuthConfig) *ResourceAuth {
ra := &ResourceAuth{
cache: c,
store: st,
jwtSecret: authCfg.JWTSecret,
limiter: NewLoginLimiter(authCfg),
grants: make(map[string]time.Time),
}
go ra.cleanupLoop()
return ra
}
func (ra *ResourceAuth) ReloadAuth(cfg *config.AuthConfig) {
ra.mu.Lock()
defer ra.mu.Unlock()
ra.jwtSecret = cfg.JWTSecret
ra.limiter = NewLoginLimiter(cfg)
}
func grantKey(resourceType string, resourceID uint, ip string) string {
return fmt.Sprintf("%s:%d:%s", resourceType, resourceID, ip)
}
func (ra *ResourceAuth) cleanupLoop() {
ticker := time.NewTicker(time.Minute)
for range ticker.C {
now := time.Now()
ra.mu.Lock()
for k, exp := range ra.grants {
if now.After(exp) {
delete(ra.grants, k)
}
}
ra.mu.Unlock()
}
}
func (ra *ResourceAuth) GrantAccess(resourceType string, resourceID uint, ip string, ttl time.Duration) {
ra.mu.Lock()
defer ra.mu.Unlock()
ra.grants[grantKey(resourceType, resourceID, ip)] = time.Now().Add(ttl)
}
func (ra *ResourceAuth) HasAccess(resourceType string, resourceID uint, ip string) bool {
ra.mu.RLock()
defer ra.mu.RUnlock()
exp, ok := ra.grants[grantKey(resourceType, resourceID, ip)]
return ok && time.Now().Before(exp)
}
func (ra *ResourceAuth) CheckHTTP(w http.ResponseWriter, r *http.Request, resourceType string, resourceID uint) bool {
cfg, userIDs, ok := GetVisitorConfig(ra.cache, resourceType, resourceID)
if ok && cfg.Enabled {
return ra.CheckVisitorHTTP(w, r, resourceType, resourceID, cfg, userIDs)
}
ip := DirectRemoteIP(r)
if ra.HasAccess(resourceType, resourceID, ip) {
return true
}
policy, ok := ra.cache.GetAuthPolicy(resourceType, resourceID)
if !ok || !policy.Enabled {
return true
}
switch policy.Type {
case "basic":
_, pass, ok := r.BasicAuth()
if ok && subtle.ConstantTimeCompare([]byte(pass), []byte(policy.Password)) == 1 {
ra.GrantAccess(resourceType, resourceID, ip, time.Duration(policy.TTLSeconds)*time.Second)
return true
}
w.Header().Set("WWW-Authenticate", `Basic realm="Wormhole"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return false
case "form":
if r.URL.Path == "/_wormhole/auth" && r.Method == http.MethodPost {
_ = r.ParseForm()
pass := r.FormValue("password")
if subtle.ConstantTimeCompare([]byte(pass), []byte(policy.Password)) == 1 {
ra.GrantAccess(resourceType, resourceID, ip, time.Duration(policy.TTLSeconds)*time.Second)
http.SetCookie(w, &http.Cookie{Name: accessCookieName, Value: "1", Path: "/", MaxAge: policy.TTLSeconds})
redirect := SafeRedirectPath(r.FormValue("redirect"))
http.Redirect(w, r, redirect, http.StatusFound)
return false
}
}
if cookie, err := r.Cookie(accessCookieName); err == nil && cookie.Value == "1" {
ra.GrantAccess(resourceType, resourceID, ip, time.Duration(policy.TTLSeconds)*time.Second)
return true
}
redirect := r.URL.RequestURI()
html := fmt.Sprintf(`<!DOCTYPE html><html><body>
<form method="POST" action="/_wormhole/auth">
<input type="hidden" name="redirect" value="%s"/>
<input type="password" name="password" placeholder="Password"/>
<button type="submit">Login</button>
</form></body></html>`, redirect)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(html))
return false
case "callback":
if policy.CallbackURL != "" {
token := r.URL.Query().Get("token")
if token != "" && token == policy.Password {
ra.GrantAccess(resourceType, resourceID, ip, time.Duration(policy.TTLSeconds)*time.Second)
return true
}
cb := policy.CallbackURL
if stringsContains(cb, "?") {
cb += "&"
} else {
cb += "?"
}
cb += "redirect=" + r.URL.RequestURI()
http.Redirect(w, r, cb, http.StatusFound)
return false
}
}
return true
}
func (ra *ResourceAuth) CheckTCP(remoteAddr net.Addr, resourceType string, resourceID uint) bool {
ip := remoteAddr.String()
if host, _, err := net.SplitHostPort(ip); err == nil {
ip = host
}
if ra.HasAccess(resourceType, resourceID, ip) {
return true
}
policy, ok := ra.cache.GetAuthPolicy(resourceType, resourceID)
if !ok || !policy.Enabled {
return true
}
return false
}
func stringsContains(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(sub) == 0 || indexOf(s, sub) >= 0)
}
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
func (ra *ResourceAuth) CreatePolicy(st *store.Store, p *store.AuthPolicy) error {
return st.CreateAuthPolicy(p)
}
+337
View File
@@ -0,0 +1,337 @@
package auth
import (
"errors"
"fmt"
"html"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/wormhole/wormhole/internal/cache"
"github.com/wormhole/wormhole/internal/store"
)
const (
visitorLoginPath = "/_wormhole/login"
visitorCookie = "wh_visitor"
)
type VisitorClaims struct {
UserID uint `json:"user_id"`
ResourceType string `json:"resource_type"`
ResourceID uint `json:"resource_id"`
jwt.RegisteredClaims
}
type VisitorConfig struct {
Enabled bool
BindMode string
UserIDs []uint
TTLSeconds int
}
func visitorConfigFromHost(h *store.Host) VisitorConfig {
ttl := h.VisitorTTLSeconds
if ttl <= 0 {
ttl = 7200
}
return VisitorConfig{
Enabled: h.VisitorAuthEnabled,
BindMode: h.VisitorBindMode,
TTLSeconds: ttl,
}
}
func visitorConfigFromTunnel(t *store.Tunnel) VisitorConfig {
ttl := t.VisitorTTLSeconds
if ttl <= 0 {
ttl = 7200
}
return VisitorConfig{
Enabled: t.VisitorAuthEnabled,
BindMode: t.VisitorBindMode,
TTLSeconds: ttl,
}
}
func (ra *ResourceAuth) CheckVisitorHTTP(w http.ResponseWriter, r *http.Request, resourceType string, resourceID uint, cfg VisitorConfig, userIDs []uint) bool {
if !cfg.Enabled {
return true
}
cfg.UserIDs = userIDs
if bindMode := strings.TrimSpace(cfg.BindMode); bindMode == "" {
cfg.BindMode = "all"
} else {
cfg.BindMode = bindMode
}
if r.URL.Path == visitorLoginPath {
ra.handleVisitorLogin(w, r, resourceType, resourceID, cfg)
return false
}
if claims, ok := ra.parseVisitorCookie(r, resourceType, resourceID); ok {
if ra.visitorAllowed(claims.UserID, cfg) {
ra.logVisitorAccess(claims.UserID, resourceType, resourceID, "access", r)
return true
}
}
redirect := r.URL.RequestURI()
http.Redirect(w, r, visitorLoginPath+"?redirect="+url.QueryEscape(redirect), http.StatusFound)
return false
}
func visitorLimiterIP(resourceType string, resourceID uint, ip string) string {
return fmt.Sprintf("%s:%d:%s", resourceType, resourceID, ip)
}
func (ra *ResourceAuth) handleVisitorLogin(w http.ResponseWriter, r *http.Request, resourceType string, resourceID uint, cfg VisitorConfig) {
ip := DirectRemoteIP(r)
scopedIP := visitorLimiterIP(resourceType, resourceID, ip)
switch r.Method {
case http.MethodPost:
_ = r.ParseForm()
username := strings.TrimSpace(r.FormValue("username"))
if retry, blocked := ra.limiter.IsBlocked(scopedIP, username); blocked {
ra.renderVisitorLoginBlocked(w, r, cfg, retry)
return
}
password := r.FormValue("password")
user, err := ra.authenticateVisitor(username, password, cfg)
if err != nil {
ra.limiter.RecordFailure(scopedIP, username)
time.Sleep(300 * time.Millisecond)
ra.renderVisitorLogin(w, r, cfg, "用户名或密码错误")
return
}
ra.limiter.Reset(scopedIP, username)
token, err := ra.signVisitorToken(user.ID, resourceType, resourceID, cfg.TTLSeconds)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
http.SetCookie(w, &http.Cookie{
Name: visitorCookie,
Value: token,
Path: "/",
MaxAge: cfg.TTLSeconds,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: secure,
})
redirect := SafeRedirectPath(r.FormValue("redirect"))
ra.logVisitorAccess(user.ID, resourceType, resourceID, "login", r)
http.Redirect(w, r, redirect, http.StatusFound)
case http.MethodGet:
if retry, blocked := ra.limiter.IsBlocked(scopedIP, ""); blocked {
ra.renderVisitorLoginBlocked(w, r, cfg, retry)
return
}
ra.renderVisitorLogin(w, r, cfg, "")
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (ra *ResourceAuth) authenticateVisitor(username, password string, cfg VisitorConfig) (*store.User, error) {
if username == "" || password == "" {
return nil, errors.New("invalid credentials")
}
user, err := ra.store.GetUserByUsername(username)
if err != nil {
return nil, errors.New("invalid credentials")
}
if user.Role != "visitor" || !user.Status {
return nil, errors.New("invalid credentials")
}
if !store.CheckPassword(user.PasswordHash, password) {
return nil, errors.New("invalid credentials")
}
if !ra.visitorAllowed(user.ID, cfg) {
return nil, errors.New("not allowed")
}
return user, nil
}
func (ra *ResourceAuth) visitorAllowed(userID uint, cfg VisitorConfig) bool {
if cfg.BindMode == "all" {
return true
}
for _, id := range cfg.UserIDs {
if id == userID {
return true
}
}
return false
}
func (ra *ResourceAuth) parseVisitorCookie(r *http.Request, resourceType string, resourceID uint) (*VisitorClaims, bool) {
cookie, err := r.Cookie(visitorCookie)
if err != nil || cookie.Value == "" {
return nil, false
}
claims, err := ra.parseVisitorToken(cookie.Value)
if err != nil {
return nil, false
}
if claims.ResourceType != resourceType || claims.ResourceID != resourceID {
return nil, false
}
return claims, true
}
func (ra *ResourceAuth) signVisitorToken(userID uint, resourceType string, resourceID uint, ttlSeconds int) (string, error) {
if ttlSeconds <= 0 {
ttlSeconds = 7200
}
claims := VisitorClaims{
UserID: userID,
ResourceType: resourceType,
ResourceID: resourceID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(ttlSeconds) * time.Second)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(ra.jwtSecret))
}
func (ra *ResourceAuth) parseVisitorToken(tokenStr string) (*VisitorClaims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &VisitorClaims{}, func(t *jwt.Token) (interface{}, error) {
return []byte(ra.jwtSecret), nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*VisitorClaims)
if !ok || !token.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}
func (ra *ResourceAuth) renderVisitorLogin(w http.ResponseWriter, r *http.Request, cfg VisitorConfig, errMsg string) {
redirect := SafeRedirectPath(r.URL.Query().Get("redirect"))
errHTML := ""
if errMsg != "" {
errHTML = fmt.Sprintf(`<p class="error">%s</p>`, html.EscapeString(errMsg))
}
page := fmt.Sprintf(`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>登录验证</title>
<style>
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f1419;color:#e7e9ea}
.card{width:100%%;max-width:380px;padding:32px 28px;background:#16181c;border:1px solid #2f3336;border-radius:16px;box-shadow:0 8px 32px rgba(0,0,0,.35)}
h1{margin:0 0 8px;font-size:22px;font-weight:600}
.sub{margin:0 0 24px;color:#71767b;font-size:14px}
label{display:block;margin-bottom:6px;font-size:13px;color:#71767b}
input{width:100%%;padding:10px 12px;margin-bottom:14px;border:1px solid #2f3336;border-radius:8px;background:#000;color:#e7e9ea;font-size:15px}
input:focus{outline:none;border-color:#1d9bf0}
button{width:100%%;padding:11px;border:none;border-radius:999px;background:#1d9bf0;color:#fff;font-size:15px;font-weight:600;cursor:pointer}
button:hover{background:#1a8cd8}
.error{color:#f4212e;font-size:13px;margin:0 0 12px}
</style>
</head>
<body>
<div class="card">
<h1>登录验证</h1>
<p class="sub">此资源已开启访客登录,请使用分配的账号访问。</p>
%s
<form method="POST" action="%s">
<input type="hidden" name="redirect" value="%s"/>
<label>用户名</label>
<input name="username" autocomplete="username" required autofocus/>
<label>密码</label>
<input name="password" type="password" autocomplete="current-password" required/>
<button type="submit">登录</button>
</form>
</div>
</body>
</html>`, errHTML, visitorLoginPath, html.EscapeString(redirect))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(page))
}
func (ra *ResourceAuth) renderVisitorLoginBlocked(w http.ResponseWriter, r *http.Request, _ VisitorConfig, retryAfter time.Duration) {
redirect := SafeRedirectPath(r.URL.Query().Get("redirect"))
minutes := int(retryAfter.Minutes())
if minutes < 1 {
minutes = 1
}
msg := fmt.Sprintf("登录尝试次数过多,请 %d 分钟后再试", minutes)
if retryAfter < time.Minute {
secs := int(retryAfter.Seconds())
if secs < 1 {
secs = 1
}
msg = fmt.Sprintf("登录尝试次数过多,请 %d 秒后再试", secs)
}
retrySecs := int(retryAfter.Seconds()) + 1
if retrySecs < 1 {
retrySecs = 1
}
w.Header().Set("Retry-After", strconv.Itoa(retrySecs))
loginURL := visitorLoginPath + "?redirect=" + url.QueryEscape(redirect)
page := fmt.Sprintf(`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>登录验证</title>
<style>
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f1419;color:#e7e9ea}
.card{width:100%%;max-width:380px;padding:32px 28px;background:#16181c;border:1px solid #2f3336;border-radius:16px;box-shadow:0 8px 32px rgba(0,0,0,.35)}
h1{margin:0 0 8px;font-size:22px;font-weight:600}
.sub{margin:0 0 16px;color:#71767b;font-size:14px}
.error{color:#f4212e;font-size:14px;margin:0 0 16px;line-height:1.5}
a{color:#1d9bf0;text-decoration:none}
</style>
</head>
<body>
<div class="card">
<h1>登录验证</h1>
<p class="sub">此资源已开启访客登录,请使用分配的账号访问。</p>
<p class="error">%s</p>
<p class="sub"><a href="%s">稍后再试</a></p>
</div>
</body>
</html>`, html.EscapeString(msg), html.EscapeString(loginURL))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(page))
}
func GetVisitorConfig(c *cache.Manager, resourceType string, resourceID uint) (VisitorConfig, []uint, bool) {
switch resourceType {
case "host":
h, ok := c.GetHost(resourceID)
if !ok {
return VisitorConfig{}, nil, false
}
cfg := visitorConfigFromHost(h)
ids, _ := c.GetResourceVisitorIDs(resourceType, resourceID)
return cfg, ids, true
case "tunnel":
t, ok := c.GetTunnel(resourceID)
if !ok {
return VisitorConfig{}, nil, false
}
cfg := visitorConfigFromTunnel(t)
ids, _ := c.GetResourceVisitorIDs(resourceType, resourceID)
return cfg, ids, true
default:
return VisitorConfig{}, nil, false
}
}
+53
View File
@@ -0,0 +1,53 @@
package auth
import (
"net/http"
"path"
"strings"
"github.com/wormhole/wormhole/internal/store"
)
func shouldLogVisitorAccess(reqPath string) bool {
if reqPath == visitorLoginPath {
return false
}
base := strings.Split(reqPath, "?")[0]
ext := strings.ToLower(path.Ext(base))
switch ext {
case ".js", ".css", ".ico", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg",
".woff", ".woff2", ".ttf", ".eot", ".map", ".wasm":
return false
}
return true
}
func (ra *ResourceAuth) logVisitorAccess(userID uint, resourceType string, resourceID uint, action string, r *http.Request) {
if ra.store == nil {
return
}
reqPath := r.URL.RequestURI()
if action == "access" && !shouldLogVisitorAccess(r.URL.Path) {
return
}
if len(reqPath) > 512 {
reqPath = reqPath[:512]
}
ua := r.UserAgent()
if len(ua) > 256 {
ua = ua[:256]
}
entry := &store.VisitorAccessLog{
UserID: userID,
ResourceType: resourceType,
ResourceID: resourceID,
Action: action,
Method: r.Method,
Path: reqPath,
IP: ExtractRemoteIP(r),
UserAgent: ua,
}
go func() {
_ = ra.store.AppendVisitorAccessLog(entry)
}()
}
+318
View File
@@ -0,0 +1,318 @@
package bridge
import (
"crypto/tls"
"fmt"
"io"
"log"
"net"
"sync"
"sync/atomic"
"time"
"github.com/xtaci/smux"
"github.com/wormhole/wormhole/internal/cache"
"github.com/wormhole/wormhole/internal/protocol"
"github.com/wormhole/wormhole/internal/store"
)
type AgentSession struct {
ClientID uint
Version string
conn net.Conn
smux *smux.Session
mu sync.Mutex
closed atomic.Bool
activeConns atomic.Int32
}
type Bridge struct {
cache *cache.Manager
store *store.Store
mu sync.RWMutex
agents map[uint]*AgentSession
listener net.Listener
}
func New(c *cache.Manager, st *store.Store) *Bridge {
return &Bridge{
cache: c,
store: st,
agents: make(map[uint]*AgentSession),
}
}
func (b *Bridge) Start(addr string, certFile, keyFile string) error {
var ln net.Listener
var err error
if certFile != "" && keyFile != "" {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return fmt.Errorf("load bridge tls cert: %w", err)
}
ln, err = tls.Listen("tcp", addr, &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
})
if err != nil {
return err
}
log.Printf("[bridge] listening on %s (TLS)", addr)
} else {
ln, err = net.Listen("tcp", addr)
if err != nil {
return err
}
log.Printf("[bridge] listening on %s (plain TCP, configure TLS cert for production)", addr)
}
b.listener = ln
go b.acceptLoop()
b.startWatchdog()
return nil
}
func (b *Bridge) Shutdown() error {
if b.listener != nil {
return b.listener.Close()
}
return nil
}
func (b *Bridge) acceptLoop() {
for {
conn, err := b.listener.Accept()
if err != nil {
return
}
go b.handleConn(conn)
}
}
func (b *Bridge) validateClient(client *store.Client) error {
if !client.Status {
return fmt.Errorf("client disabled")
}
if client.ExpireAt != nil && time.Now().After(*client.ExpireAt) {
return fmt.Errorf("client expired")
}
return nil
}
func (b *Bridge) handleConn(conn net.Conn) {
defer conn.Close()
SetTCPKeepAlive(conn)
msgType, body, err := protocol.ReadMessage(conn)
if err != nil {
log.Printf("[bridge] read register failed: %v", err)
return
}
if msgType != protocol.MsgRegister {
return
}
reg, err := protocol.ParseRegister(body)
if err != nil {
return
}
client, ok := b.cache.GetClientByKey(reg.VerifyKey)
if !ok {
_ = protocol.WriteMessage(conn, protocol.MsgRegisterAck, protocol.RegisterAckPayload{
OK: false,
Message: "invalid verify key",
})
return
}
if err := b.validateClient(client); err != nil {
_ = protocol.WriteMessage(conn, protocol.MsgRegisterAck, protocol.RegisterAckPayload{
OK: false,
Message: err.Error(),
})
return
}
if err := protocol.WriteMessage(conn, protocol.MsgRegisterAck, protocol.RegisterAckPayload{
OK: true,
ClientID: client.ID,
Message: "registered",
}); err != nil {
return
}
session, err := smux.Client(conn, smuxConfig())
if err != nil {
log.Printf("[bridge] smux client failed: %v", err)
return
}
agent := &AgentSession{
ClientID: client.ID,
Version: reg.Version,
conn: conn,
smux: session,
}
b.mu.Lock()
if old, exists := b.agents[client.ID]; exists {
old.Close()
}
b.agents[client.ID] = agent
b.mu.Unlock()
b.cache.SetClientOnline(client.ID, reg.Version)
log.Printf("[bridge] client %d online (version=%s)", client.ID, reg.Version)
b.controlLoop(agent)
b.mu.Lock()
delete(b.agents, client.ID)
b.mu.Unlock()
b.cache.SetClientOffline(client.ID)
log.Printf("[bridge] client %d offline", client.ID)
}
func (b *Bridge) controlLoop(agent *AgentSession) {
for {
stream, err := agent.smux.AcceptStream()
if err != nil {
agent.Close()
return
}
go b.handleControlStream(agent, stream)
}
}
func (b *Bridge) handleControlStream(agent *AgentSession, stream *smux.Stream) {
defer stream.Close()
msgType, body, err := protocol.ReadMessage(stream)
if err != nil {
return
}
switch msgType {
case protocol.MsgPing:
b.cache.TouchAgent(agent.ClientID)
_ = protocol.WriteMessage(stream, protocol.MsgPong, nil)
case protocol.MsgClose:
agent.Close()
}
_ = body
}
func (a *AgentSession) Close() {
if a.closed.Swap(true) {
return
}
if a.smux != nil {
_ = a.smux.Close()
}
if a.conn != nil {
_ = a.conn.Close()
}
}
func (b *Bridge) OpenStream(clientID uint, meta *protocol.LinkMeta) (net.Conn, error) {
client, err := b.store.GetClientByID(clientID)
if err != nil {
return nil, fmt.Errorf("client %d not found", clientID)
}
if err := b.validateClient(client); err != nil {
return nil, err
}
b.mu.RLock()
agent, ok := b.agents[clientID]
b.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("client %d offline", clientID)
}
if agent.closed.Load() {
return nil, fmt.Errorf("client %d session closed", clientID)
}
if client.MaxConn > 0 && int(agent.activeConns.Load()) >= client.MaxConn {
return nil, fmt.Errorf("client %d max connections reached", clientID)
}
stream, err := agent.smux.OpenStream()
if err != nil {
return nil, err
}
if err := protocol.WriteMessage(stream, protocol.MsgOpenStream, meta); err != nil {
stream.Close()
return nil, err
}
msgType, _, err := protocol.ReadMessage(stream)
if err != nil {
stream.Close()
return nil, err
}
if msgType != protocol.MsgStreamReady {
stream.Close()
return nil, fmt.Errorf("stream not ready")
}
agent.activeConns.Add(1)
var wrapped net.Conn = &trackedConn{
Conn: stream,
onClose: func() { agent.activeConns.Add(-1) },
}
if client.RateLimit > 0 {
wrapped = newRateLimitedConn(wrapped, client.RateLimit*1024)
}
return wrapped, nil
}
func (b *Bridge) IsOnline(clientID uint) bool {
b.mu.RLock()
defer b.mu.RUnlock()
_, ok := b.agents[clientID]
return ok
}
func (b *Bridge) OnlineCount() int {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.agents)
}
type trackedConn struct {
net.Conn
onClose func()
once sync.Once
}
func (c *trackedConn) Close() error {
c.once.Do(c.onClose)
return c.Conn.Close()
}
func CopyBoth(a, b net.Conn) (inlet, export int64) {
var inVal, outVal int64
done := make(chan struct{}, 2)
go func() {
n, _ := io.Copy(b, a)
atomic.AddInt64(&outVal, n)
done <- struct{}{}
}()
go func() {
n, _ := io.Copy(a, b)
atomic.AddInt64(&inVal, n)
done <- struct{}{}
}()
<-done
<-done
return atomic.LoadInt64(&inVal), atomic.LoadInt64(&outVal)
}
func CopyBothIO(a io.ReadWriteCloser, b io.ReadWriteCloser) (inlet, export int64) {
var inVal, outVal int64
done := make(chan struct{}, 2)
go func() {
n, _ := io.Copy(b, a)
atomic.AddInt64(&outVal, n)
done <- struct{}{}
}()
go func() {
n, _ := io.Copy(a, b)
atomic.AddInt64(&inVal, n)
done <- struct{}{}
}()
<-done
<-done
return atomic.LoadInt64(&inVal), atomic.LoadInt64(&outVal)
}
+46
View File
@@ -0,0 +1,46 @@
package bridge
import (
"crypto/tls"
"net"
"time"
"github.com/xtaci/smux"
)
const (
// HeartbeatInterval is the application-level ping interval (agent and server probe).
HeartbeatInterval = 30 * time.Second
// HeartbeatTimeout evicts a session when no ping/pong for this duration.
HeartbeatTimeout = 90 * time.Second
// WatchdogInterval is how often the server checks agent heartbeats.
WatchdogInterval = 15 * time.Second
// ServerProbeAfter triggers a server-initiated ping when agent has been silent.
ServerProbeAfter = 45 * time.Second
)
func SmuxConfig() *smux.Config {
return smuxConfig()
}
func smuxConfig() *smux.Config {
cfg := smux.DefaultConfig()
cfg.KeepAliveInterval = 10 * time.Second
cfg.KeepAliveTimeout = 30 * time.Second
return cfg
}
func SetTCPKeepAlive(conn net.Conn) {
for {
switch c := conn.(type) {
case *net.TCPConn:
_ = c.SetKeepAlive(true)
_ = c.SetKeepAlivePeriod(30 * time.Second)
return
case *tls.Conn:
conn = c.NetConn()
default:
return
}
}
}
+74
View File
@@ -0,0 +1,74 @@
package bridge
import (
"net"
"sync"
"time"
)
// rateLimitedConn limits read/write throughput to bytesPerSec (simple token bucket).
type rateLimitedConn struct {
net.Conn
bytesPerSec int64
mu sync.Mutex
allowance float64
lastCheck time.Time
}
func newRateLimitedConn(conn net.Conn, bytesPerSec int64) net.Conn {
if bytesPerSec <= 0 {
return conn
}
return &rateLimitedConn{
Conn: conn,
bytesPerSec: bytesPerSec,
lastCheck: time.Now(),
allowance: float64(bytesPerSec),
}
}
func (c *rateLimitedConn) wait(n int) {
if c.bytesPerSec <= 0 || n <= 0 {
return
}
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
elapsed := now.Sub(c.lastCheck).Seconds()
c.lastCheck = now
c.allowance += elapsed * float64(c.bytesPerSec)
if c.allowance > float64(c.bytesPerSec)*2 {
c.allowance = float64(c.bytesPerSec) * 2
}
for float64(n) > c.allowance {
deficit := float64(n) - c.allowance
sleep := time.Duration(deficit/float64(c.bytesPerSec)*1e9) * time.Nanosecond
if sleep < time.Millisecond {
sleep = time.Millisecond
}
c.mu.Unlock()
time.Sleep(sleep)
c.mu.Lock()
now = time.Now()
elapsed = now.Sub(c.lastCheck).Seconds()
c.lastCheck = now
c.allowance += elapsed * float64(c.bytesPerSec)
if c.allowance > float64(c.bytesPerSec)*2 {
c.allowance = float64(c.bytesPerSec) * 2
}
}
c.allowance -= float64(n)
}
func (c *rateLimitedConn) Read(b []byte) (int, error) {
n, err := c.Conn.Read(b)
if n > 0 {
c.wait(n)
}
return n, err
}
func (c *rateLimitedConn) Write(b []byte) (int, error) {
c.wait(len(b))
return c.Conn.Write(b)
}
+67
View File
@@ -0,0 +1,67 @@
package bridge
import (
"log"
"time"
"github.com/wormhole/wormhole/internal/protocol"
)
func (b *Bridge) startWatchdog() {
go func() {
ticker := time.NewTicker(WatchdogInterval)
defer ticker.Stop()
for range ticker.C {
b.runWatchdog()
}
}()
}
func (b *Bridge) runWatchdog() {
now := time.Now()
b.mu.RLock()
sessions := make([]*AgentSession, 0, len(b.agents))
for _, agent := range b.agents {
sessions = append(sessions, agent)
}
b.mu.RUnlock()
for _, agent := range sessions {
if agent.closed.Load() {
continue
}
lastPing, ok := b.cache.AgentLastPing(agent.ClientID)
if !ok {
continue
}
since := now.Sub(lastPing)
if since > HeartbeatTimeout {
log.Printf("[bridge] client %d heartbeat timeout (%v), closing", agent.ClientID, since.Round(time.Second))
agent.Close()
continue
}
if since > ServerProbeAfter {
go b.probeAgent(agent)
}
}
}
func (b *Bridge) probeAgent(agent *AgentSession) {
if agent.closed.Load() {
return
}
stream, err := agent.smux.OpenStream()
if err != nil {
return
}
defer stream.Close()
if err := protocol.WriteMessage(stream, protocol.MsgPing, nil); err != nil {
return
}
msgType, _, err := protocol.ReadMessage(stream)
if err != nil || msgType != protocol.MsgPong {
return
}
b.cache.TouchAgent(agent.ClientID)
}
+605
View File
@@ -0,0 +1,605 @@
package cache
import (
"net"
"regexp"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/wormhole/wormhole/internal/store"
)
type OnlineAgent struct {
ClientID uint
Version string
ConnectedAt time.Time
LastPing time.Time
}
type CompiledRule struct {
Rule store.IPRule
CIDR *net.IPNet
Regex *regexp.Regexp
ExactIP net.IP
}
type FlowDelta struct {
Inlet int64
Export int64
}
type Manager struct {
mu sync.RWMutex
clients map[uint]*store.Client
clientsByKey map[string]*store.Client
tunnels map[uint]*store.Tunnel
tunnelsByPort map[string]*store.Tunnel
hosts map[uint]*store.Host
hostIndex []hostEntry
forwards map[uint]*store.DomainForward
forwardIndex []forwardEntry
onlineAgents map[uint]*OnlineAgent
ipRules []CompiledRule
authPolicies map[string]*store.AuthPolicy
visitorBindings map[string][]uint
flowCounters map[string]*FlowDelta
}
type hostEntry struct {
host *store.Host
}
type forwardEntry struct {
forward *store.DomainForward
}
func NewManager() *Manager {
return &Manager{
clients: make(map[uint]*store.Client),
clientsByKey: make(map[string]*store.Client),
tunnels: make(map[uint]*store.Tunnel),
tunnelsByPort: make(map[string]*store.Tunnel),
hosts: make(map[uint]*store.Host),
forwards: make(map[uint]*store.DomainForward),
onlineAgents: make(map[uint]*OnlineAgent),
authPolicies: make(map[string]*store.AuthPolicy),
visitorBindings: make(map[string][]uint),
flowCounters: make(map[string]*FlowDelta),
}
}
func (m *Manager) ReloadAll(st *store.Store) error {
clients, err := st.ListClients(0, true)
if err != nil {
return err
}
tunnels, err := st.ListTunnels(0, 0, true)
if err != nil {
return err
}
hosts, err := st.ListHosts(0, 0, true)
if err != nil {
return err
}
rules, err := st.ListIPRules("", 0)
if err != nil {
return err
}
policies, err := st.ListAuthPolicies("", 0)
if err != nil {
return err
}
visitors, err := st.ListAllResourceVisitors()
if err != nil {
return err
}
forwards, err := st.ListDomainForwards()
if err != nil {
return err
}
m.mu.Lock()
defer m.mu.Unlock()
m.clients = make(map[uint]*store.Client)
m.clientsByKey = make(map[string]*store.Client)
for i := range clients {
c := clients[i]
m.clients[c.ID] = &c
m.clientsByKey[c.VerifyKey] = &c
}
m.tunnels = make(map[uint]*store.Tunnel)
m.tunnelsByPort = make(map[string]*store.Tunnel)
for i := range tunnels {
t := tunnels[i]
m.tunnels[t.ID] = &t
key := tunnelPortKey(t.ListenIP, t.ListenPort)
m.tunnelsByPort[key] = &t
}
m.hosts = make(map[uint]*store.Host)
m.hostIndex = nil
for i := range hosts {
h := hosts[i]
m.hosts[h.ID] = &h
m.hostIndex = append(m.hostIndex, hostEntry{host: &h})
}
m.forwards = make(map[uint]*store.DomainForward)
m.forwardIndex = nil
for i := range forwards {
f := forwards[i]
m.forwards[f.ID] = &f
m.forwardIndex = append(m.forwardIndex, forwardEntry{forward: &f})
}
m.ipRules = compileRules(rules)
m.authPolicies = make(map[string]*store.AuthPolicy)
for i := range policies {
p := policies[i]
key := authPolicyKey(p.ResourceType, p.ResourceID)
m.authPolicies[key] = &p
}
m.visitorBindings = make(map[string][]uint)
for i := range visitors {
v := visitors[i]
key := authPolicyKey(v.ResourceType, v.ResourceID)
m.visitorBindings[key] = append(m.visitorBindings[key], v.UserID)
}
return nil
}
func tunnelPortKey(ip string, port int) string {
if ip == "" {
ip = "0.0.0.0"
}
return ip + ":" + strconv.Itoa(port)
}
func authPolicyKey(resourceType string, resourceID uint) string {
return resourceType + ":" + itoa(resourceID)
}
func itoa(n uint) string {
if n == 0 {
return "0"
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
func compileRules(rules []store.IPRule) []CompiledRule {
out := make([]CompiledRule, 0, len(rules))
for _, r := range rules {
cr := CompiledRule{Rule: r}
switch r.PatternKind {
case "cidr":
_, network, err := net.ParseCIDR(r.Pattern)
if err == nil {
cr.CIDR = network
}
case "regex":
re, err := regexp.Compile(r.Pattern)
if err == nil {
cr.Regex = re
}
default:
cr.ExactIP = net.ParseIP(r.Pattern)
}
out = append(out, cr)
}
return out
}
func (m *Manager) SetClientOnline(clientID uint, version string) {
m.mu.Lock()
defer m.mu.Unlock()
now := time.Now()
m.onlineAgents[clientID] = &OnlineAgent{
ClientID: clientID,
Version: version,
ConnectedAt: now,
LastPing: now,
}
}
func (m *Manager) SetClientOffline(clientID uint) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.onlineAgents, clientID)
}
func (m *Manager) TouchAgent(clientID uint) {
m.mu.Lock()
defer m.mu.Unlock()
if a, ok := m.onlineAgents[clientID]; ok {
a.LastPing = time.Now()
}
}
func (m *Manager) AgentLastPing(clientID uint) (time.Time, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
a, ok := m.onlineAgents[clientID]
if !ok {
return time.Time{}, false
}
return a.LastPing, true
}
func (m *Manager) IsOnline(clientID uint) bool {
m.mu.RLock()
defer m.mu.RUnlock()
_, ok := m.onlineAgents[clientID]
return ok
}
func (m *Manager) OnlineCount() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.onlineAgents)
}
func (m *Manager) GetClientByKey(key string) (*store.Client, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
c, ok := m.clientsByKey[key]
return c, ok
}
func (m *Manager) GetClient(id uint) (*store.Client, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
c, ok := m.clients[id]
return c, ok
}
func (m *Manager) GetTunnel(id uint) (*store.Tunnel, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
t, ok := m.tunnels[id]
return t, ok
}
func (m *Manager) GetHost(id uint) (*store.Host, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
h, ok := m.hosts[id]
return h, ok
}
func (m *Manager) MatchHost(hostHeader, path, scheme string) *store.Host {
m.mu.RLock()
defer m.mu.RUnlock()
hostHeader = strings.ToLower(strings.Split(hostHeader, ":")[0])
var best *store.Host
bestLen := -1
for _, entry := range m.hostIndex {
h := entry.host
if !h.Status {
continue
}
if h.Scheme != "all" && h.Scheme != scheme {
continue
}
if !matchHostPattern(h.Host, hostHeader) {
continue
}
loc := h.Location
if loc == "" {
loc = "/"
}
if !strings.HasPrefix(path, loc) {
continue
}
if len(loc) > bestLen {
best = h
bestLen = len(loc)
}
}
return best
}
func (m *Manager) MatchDomainForward(hostHeader, path, scheme string) *store.DomainForward {
m.mu.RLock()
defer m.mu.RUnlock()
hostHeader = strings.ToLower(strings.Split(hostHeader, ":")[0])
var best *store.DomainForward
bestLen := -1
for _, entry := range m.forwardIndex {
f := entry.forward
if !f.Status {
continue
}
if f.SourceScheme != "all" && f.SourceScheme != scheme {
continue
}
if !matchHostPattern(f.SourceHost, hostHeader) {
continue
}
loc := f.SourceLocation
if loc == "" {
loc = "/"
}
if !strings.HasPrefix(path, loc) {
continue
}
if len(loc) > bestLen {
best = f
bestLen = len(loc)
}
}
return best
}
func matchHostPattern(pattern, host string) bool {
pattern = strings.ToLower(pattern)
if pattern == host {
return true
}
if strings.HasPrefix(pattern, "*.") {
suffix := pattern[1:]
return strings.HasSuffix(host, suffix) || host == pattern[2:]
}
return false
}
func (m *Manager) ReloadIPRules(st *store.Store) error {
rules, err := st.ListIPRules("", 0)
if err != nil {
return err
}
m.mu.Lock()
m.ipRules = compileRules(rules)
m.mu.Unlock()
return nil
}
func (m *Manager) AllowIP(ipStr string, scopes []Scope) bool {
ip := net.ParseIP(ipStr)
if ip == nil {
return false
}
m.mu.RLock()
defer m.mu.RUnlock()
var denyMatched, allowMatched bool
hasAllowRules := false
for _, cr := range m.ipRules {
if !scopeMatch(cr.Rule, scopes) {
continue
}
if !ruleMatch(cr, ip, ipStr) {
continue
}
if cr.Rule.Type == "deny" {
denyMatched = true
}
if cr.Rule.Type == "allow" {
allowMatched = true
hasAllowRules = true
}
}
if denyMatched {
return false
}
if hasAllowRules {
return allowMatched
}
return true
}
type Scope struct {
Kind string
ResourceID uint
}
func scopeMatch(rule store.IPRule, scopes []Scope) bool {
if rule.Scope == "global" {
return true
}
for _, s := range scopes {
if rule.Scope == s.Kind && rule.ResourceID == s.ResourceID {
return true
}
}
return false
}
func ruleMatch(cr CompiledRule, ip net.IP, ipStr string) bool {
switch cr.Rule.PatternKind {
case "cidr":
return cr.CIDR != nil && cr.CIDR.Contains(ip)
case "regex":
return cr.Regex != nil && cr.Regex.MatchString(ipStr)
default:
return cr.ExactIP != nil && cr.ExactIP.Equal(ip)
}
}
func (m *Manager) GetAuthPolicy(resourceType string, resourceID uint) (*store.AuthPolicy, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
p, ok := m.authPolicies[authPolicyKey(resourceType, resourceID)]
return p, ok
}
func (m *Manager) GetResourceVisitorIDs(resourceType string, resourceID uint) ([]uint, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
ids, ok := m.visitorBindings[authPolicyKey(resourceType, resourceID)]
if !ok {
return nil, false
}
out := make([]uint, len(ids))
copy(out, ids)
return out, true
}
func (m *Manager) SetResourceVisitors(resourceType string, resourceID uint, userIDs []uint) {
m.mu.Lock()
defer m.mu.Unlock()
key := authPolicyKey(resourceType, resourceID)
if len(userIDs) == 0 {
delete(m.visitorBindings, key)
return
}
copied := make([]uint, len(userIDs))
copy(copied, userIDs)
m.visitorBindings[key] = copied
}
func (m *Manager) DeleteResourceVisitors(resourceType string, resourceID uint) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.visitorBindings, authPolicyKey(resourceType, resourceID))
}
func (m *Manager) AddFlow(resourceType string, resourceID uint, inlet, export int64) {
key := resourceType + ":" + itoa(resourceID)
m.mu.Lock()
defer m.mu.Unlock()
delta, ok := m.flowCounters[key]
if !ok {
delta = &FlowDelta{}
m.flowCounters[key] = delta
}
atomic.AddInt64(&delta.Inlet, inlet)
atomic.AddInt64(&delta.Export, export)
}
func (m *Manager) DrainFlow() map[string]FlowDelta {
m.mu.Lock()
defer m.mu.Unlock()
out := make(map[string]FlowDelta, len(m.flowCounters))
for k, v := range m.flowCounters {
inlet := atomic.SwapInt64(&v.Inlet, 0)
export := atomic.SwapInt64(&v.Export, 0)
if inlet > 0 || export > 0 {
out[k] = FlowDelta{Inlet: inlet, Export: export}
}
}
return out
}
func (m *Manager) UpsertClient(c *store.Client) {
m.mu.Lock()
defer m.mu.Unlock()
m.clients[c.ID] = c
m.clientsByKey[c.VerifyKey] = c
}
func (m *Manager) DeleteClient(id uint) {
m.mu.Lock()
defer m.mu.Unlock()
if c, ok := m.clients[id]; ok {
delete(m.clientsByKey, c.VerifyKey)
}
delete(m.clients, id)
delete(m.onlineAgents, id)
}
func (m *Manager) UpsertTunnel(t *store.Tunnel) {
m.mu.Lock()
defer m.mu.Unlock()
m.tunnels[t.ID] = t
m.tunnelsByPort[tunnelPortKey(t.ListenIP, t.ListenPort)] = t
}
func (m *Manager) DeleteTunnel(id uint) {
m.mu.Lock()
defer m.mu.Unlock()
if t, ok := m.tunnels[id]; ok {
delete(m.tunnelsByPort, tunnelPortKey(t.ListenIP, t.ListenPort))
}
delete(m.tunnels, id)
}
func (m *Manager) UpsertHost(h *store.Host) {
m.mu.Lock()
defer m.mu.Unlock()
m.hosts[h.ID] = h
m.hostIndex = nil
for _, host := range m.hosts {
m.hostIndex = append(m.hostIndex, hostEntry{host: host})
}
}
func (m *Manager) DeleteHost(id uint) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.hosts, id)
m.hostIndex = nil
for _, host := range m.hosts {
m.hostIndex = append(m.hostIndex, hostEntry{host: host})
}
}
func (m *Manager) UpsertDomainForward(f *store.DomainForward) {
m.mu.Lock()
defer m.mu.Unlock()
m.forwards[f.ID] = f
m.forwardIndex = nil
for _, fwd := range m.forwards {
m.forwardIndex = append(m.forwardIndex, forwardEntry{forward: fwd})
}
}
func (m *Manager) DeleteDomainForward(id uint) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.forwards, id)
m.forwardIndex = nil
for _, fwd := range m.forwards {
m.forwardIndex = append(m.forwardIndex, forwardEntry{forward: fwd})
}
}
func (m *Manager) GetDomainForward(id uint) (*store.DomainForward, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
f, ok := m.forwards[id]
return f, ok
}
func (m *Manager) ListEnabledTunnels() []*store.Tunnel {
m.mu.RLock()
defer m.mu.RUnlock()
var out []*store.Tunnel
for _, t := range m.tunnels {
if t.Status {
out = append(out, t)
}
}
return out
}
func (m *Manager) SetTunnelRunStatus(id uint, running bool) {
m.mu.Lock()
defer m.mu.Unlock()
if t, ok := m.tunnels[id]; ok {
t.RunStatus = running
}
}
+200
View File
@@ -0,0 +1,200 @@
package config
import (
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"time"
"gopkg.in/yaml.v3"
)
const DefaultDBPath = "./data/wormhole.db"
type Config struct {
Server ServerConfig `yaml:"server" json:"server"`
Database DatabaseConfig `yaml:"database" json:"database"`
Auth AuthConfig `yaml:"auth" json:"auth"`
Metrics MetricsConfig `yaml:"metrics" json:"metrics"`
}
type ServerConfig struct {
BridgeAddr string `yaml:"bridge_addr" json:"bridge_addr"`
HTTPAddr string `yaml:"http_addr" json:"http_addr"`
HTTPProxyPort int `yaml:"http_proxy_port" json:"http_proxy_port"`
HTTPSProxyPort int `yaml:"https_proxy_port" json:"https_proxy_port"`
TLSCertFile string `yaml:"tls_cert_file" json:"tls_cert_file"`
TLSKeyFile string `yaml:"tls_key_file" json:"tls_key_file"`
}
type DatabaseConfig struct {
Path string `yaml:"path" json:"path"`
}
type AuthConfig struct {
JWTSecret string `yaml:"jwt_secret" json:"jwt_secret"`
TokenTTL time.Duration `yaml:"token_ttl" json:"token_ttl"`
LoginMaxAttempts int `yaml:"login_max_attempts" json:"login_max_attempts"`
LoginWindow time.Duration `yaml:"login_window" json:"login_window"`
LoginLockout time.Duration `yaml:"login_lockout" json:"login_lockout"`
}
type MetricsConfig struct {
FlushInterval time.Duration `yaml:"flush_interval" json:"flush_interval"`
}
// SettingsDTO is the API/UI representation of runtime settings.
type SettingsDTO struct {
Server ServerConfig `json:"server"`
Auth AuthSettings `json:"auth"`
Metrics struct {
FlushIntervalSec int `json:"flush_interval_sec"`
} `json:"metrics"`
}
type AuthSettings struct {
JWTSecret string `json:"jwt_secret,omitempty"`
TokenTTLHours int `json:"token_ttl_hours"`
LoginMaxAttempts int `json:"login_max_attempts"`
LoginWindowMin int `json:"login_window_min"`
LoginLockoutMin int `json:"login_lockout_min"`
}
func Default() *Config {
secret, _ := randomSecret(32)
return &Config{
Server: ServerConfig{
BridgeAddr: ":8528",
HTTPAddr: ":8529",
HTTPProxyPort: 8081,
HTTPSProxyPort: 8443,
},
Database: DatabaseConfig{
Path: DefaultDBPath,
},
Auth: AuthConfig{
JWTSecret: secret,
TokenTTL: 24 * time.Hour,
LoginMaxAttempts: 5,
LoginWindow: 5 * time.Minute,
LoginLockout: 15 * time.Minute,
},
Metrics: MetricsConfig{
FlushInterval: 30 * time.Second,
},
}
}
func randomSecret(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "wormhole-change-me-in-production", err
}
return hex.EncodeToString(b), nil
}
func (c *Config) Normalize() {
if c.Server.BridgeAddr == "" {
c.Server.BridgeAddr = ":8528"
}
if c.Server.HTTPAddr == "" {
c.Server.HTTPAddr = ":8529"
}
if c.Server.HTTPProxyPort == 0 {
c.Server.HTTPProxyPort = 8081
}
if c.Server.HTTPSProxyPort == 0 {
c.Server.HTTPSProxyPort = 8443
}
if c.Database.Path == "" {
c.Database.Path = DefaultDBPath
}
if c.Auth.TokenTTL == 0 {
c.Auth.TokenTTL = 24 * time.Hour
}
if c.Auth.LoginMaxAttempts == 0 {
c.Auth.LoginMaxAttempts = 5
}
if c.Auth.LoginWindow == 0 {
c.Auth.LoginWindow = 5 * time.Minute
}
if c.Auth.LoginLockout == 0 {
c.Auth.LoginLockout = 15 * time.Minute
}
if c.Auth.JWTSecret == "" {
secret, _ := randomSecret(32)
c.Auth.JWTSecret = secret
}
if c.Metrics.FlushInterval == 0 {
c.Metrics.FlushInterval = 30 * time.Second
}
}
func (c *Config) ToDTO() SettingsDTO {
sec := int(c.Metrics.FlushInterval / time.Second)
if sec < 1 {
sec = 30
}
dto := SettingsDTO{
Server: c.Server,
Auth: AuthSettings{
TokenTTLHours: int(c.Auth.TokenTTL / time.Hour),
LoginMaxAttempts: c.Auth.LoginMaxAttempts,
LoginWindowMin: int(c.Auth.LoginWindow / time.Minute),
LoginLockoutMin: int(c.Auth.LoginLockout / time.Minute),
},
}
dto.Metrics.FlushIntervalSec = sec
return dto
}
func SettingsFromDTO(d SettingsDTO, existing *Config) *Config {
cfg := Default()
if existing != nil {
*cfg = *existing
}
cfg.Server = d.Server
cfg.Auth.TokenTTL = time.Duration(d.Auth.TokenTTLHours) * time.Hour
cfg.Auth.LoginMaxAttempts = d.Auth.LoginMaxAttempts
cfg.Auth.LoginWindow = time.Duration(d.Auth.LoginWindowMin) * time.Minute
cfg.Auth.LoginLockout = time.Duration(d.Auth.LoginLockoutMin) * time.Minute
if d.Auth.JWTSecret != "" {
cfg.Auth.JWTSecret = d.Auth.JWTSecret
}
if d.Metrics.FlushIntervalSec > 0 {
cfg.Metrics.FlushInterval = time.Duration(d.Metrics.FlushIntervalSec) * time.Second
}
cfg.Normalize()
return cfg
}
func (c *Config) Summary() string {
return fmt.Sprintf(
"http_addr=%s bridge_addr=%s http_proxy_port=%d https_proxy_port=%d db=%s",
c.Server.HTTPAddr,
c.Server.BridgeAddr,
c.Server.HTTPProxyPort,
c.Server.HTTPSProxyPort,
c.Database.Path,
)
}
// Load reads config from a YAML file (tests only).
func Load(path string) (*Config, error) {
if path == "" {
return nil, fmt.Errorf("config path required")
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
cfg := Default()
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, err
}
cfg.Normalize()
return cfg, nil
}
+33
View File
@@ -0,0 +1,33 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadUsesFileValues(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "wormhole.yaml")
content := []byte(`server:
bridge_addr: ":9528"
http_addr: ":9529"
database:
path: "./tmp.db"
auth:
jwt_secret: "test-secret"
token_ttl: 1h
metrics:
flush_interval: 10s
`)
if err := os.WriteFile(cfgPath, content, 0644); err != nil {
t.Fatal(err)
}
cfg, err := Load(cfgPath)
if err != nil {
t.Fatal(err)
}
if cfg.Server.BridgeAddr != ":9528" || cfg.Server.HTTPAddr != ":9529" {
t.Fatalf("server config not applied: %+v", cfg.Server)
}
}
+86
View File
@@ -0,0 +1,86 @@
package forwardplugin
import (
"encoding/json"
"fmt"
"net/http"
"strings"
)
func init() {
Register("cookie_domain", newCookieDomainPlugin)
}
type cookieDomainConfig struct {
From string `json:"from"`
To string `json:"to"`
}
type cookieDomainPlugin struct {
from string
to string
}
func newCookieDomainPlugin(raw json.RawMessage) (Plugin, error) {
var cfg cookieDomainConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
return nil, fmt.Errorf("cookie_domain 配置无效")
}
return &cookieDomainPlugin{from: cfg.From, to: cfg.To}, nil
}
func (p *cookieDomainPlugin) Type() string { return "cookie_domain" }
func (p *cookieDomainPlugin) ValidateConfig(raw json.RawMessage) error {
var cfg cookieDomainConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
return fmt.Errorf("cookie_domain 配置无效")
}
if strings.TrimSpace(cfg.From) == "" {
return fmt.Errorf("cookie_domain.from 不能为空")
}
if strings.TrimSpace(cfg.To) == "" {
return fmt.Errorf("cookie_domain.to 不能为空")
}
return nil
}
func (p *cookieDomainPlugin) NeedsBody() bool { return false }
func (p *cookieDomainPlugin) HandleRequest(_ *Context, _ *http.Request) (bool, error) {
return false, nil
}
func (p *cookieDomainPlugin) HandleResponse(_ *Context, resp *BufferedResponse) error {
cookies := resp.Header.Values("Set-Cookie")
if len(cookies) == 0 {
return nil
}
resp.Header.Del("Set-Cookie")
for _, c := range cookies {
resp.Header.Add("Set-Cookie", rewriteCookieDomain(c, p.from, p.to))
}
return nil
}
func rewriteCookieDomain(cookie, from, to string) string {
parts := strings.Split(cookie, ";")
for i, part := range parts {
trimmed := strings.TrimSpace(part)
kv := strings.SplitN(trimmed, "=", 2)
if len(kv) != 2 {
continue
}
if strings.EqualFold(kv[0], "domain") {
val := strings.TrimSpace(kv[1])
if val == from || strings.TrimPrefix(val, ".") == strings.TrimPrefix(from, ".") {
parts[i] = "Domain=" + to
} else {
parts[i] = trimmed
}
} else {
parts[i] = trimmed
}
}
return strings.Join(parts, "; ")
}
+64
View File
@@ -0,0 +1,64 @@
package forwardplugin
type PluginTypeMeta struct {
Type string `json:"type"`
Label string `json:"label"`
Description string `json:"description"`
ConfigSchema interface{} `json:"config_schema"`
DefaultConfig interface{} `json:"default_config"`
}
func ListPluginTypes() []PluginTypeMeta {
return []PluginTypeMeta{
{
Type: "sub_filter",
Label: "内容替换",
Description: "按 MIME 类型对响应体做字符串替换",
ConfigSchema: map[string]interface{}{
"filters": []map[string]string{{"from": "", "to": ""}},
"types": []string{"text/html"},
},
DefaultConfig: map[string]interface{}{
"filters": []map[string]string{{"from": "", "to": ""}},
"types": []string{"text/html"},
},
},
{
Type: "inject_js",
Label: "JS 注入",
Description: "在 HTML </body> 前注入脚本",
ConfigSchema: map[string]interface{}{
"script": "",
},
DefaultConfig: map[string]interface{}{
"script": "<script></script>",
},
},
{
Type: "cookie_domain",
Label: "Cookie 域改写",
Description: "改写 Set-Cookie 的 Domain 属性",
ConfigSchema: map[string]interface{}{
"from": "",
"to": "",
},
DefaultConfig: map[string]interface{}{
"from": ".example.com",
"to": ".proxy.com",
},
},
{
Type: "response_cache",
Label: "GET 响应缓存",
Description: "缓存上游原始 GET 响应(不含 Set-Cookie",
ConfigSchema: map[string]interface{}{
"max_mb": 0,
"ttl_sec": 300,
},
DefaultConfig: map[string]interface{}{
"max_mb": 50,
"ttl_sec": 300,
},
},
}
}
+118
View File
@@ -0,0 +1,118 @@
package forwardplugin
import (
"encoding/json"
"net/http"
"sync"
)
type PluginEntry struct {
Type string `json:"type"`
Enabled bool `json:"enabled"`
Config json.RawMessage `json:"config"`
}
type Plugin interface {
Type() string
ValidateConfig(raw json.RawMessage) error
NeedsBody() bool
HandleRequest(ctx *Context, req *http.Request) (shortCircuit bool, err error)
HandleResponse(ctx *Context, resp *BufferedResponse) error
}
type PluginFactory func(config json.RawMessage) (Plugin, error)
type RawStorer interface {
StoreRaw(ctx *Context, resp *BufferedResponse)
}
var (
registryMu sync.RWMutex
registry = map[string]PluginFactory{}
)
func Register(typ string, factory PluginFactory) {
registryMu.Lock()
defer registryMu.Unlock()
registry[typ] = factory
}
func getFactory(typ string) (PluginFactory, bool) {
registryMu.RLock()
defer registryMu.RUnlock()
f, ok := registry[typ]
return f, ok
}
type Context struct {
ForwardID uint
ClientReq *http.Request
CachedResp *BufferedResponse
}
type BufferedResponse struct {
StatusCode int
Header http.Header
Body []byte
FromCache bool
}
func (r *BufferedResponse) Clone() *BufferedResponse {
if r == nil {
return nil
}
h := r.Header.Clone()
return &BufferedResponse{
StatusCode: r.StatusCode,
Header: h,
Body: append([]byte(nil), r.Body...),
FromCache: r.FromCache,
}
}
type Chain struct {
plugins []Plugin
}
func (c *Chain) Empty() bool {
return c == nil || len(c.plugins) == 0
}
func (c *Chain) NeedsBody() bool {
for _, p := range c.plugins {
if p.NeedsBody() {
return true
}
}
return false
}
func (c *Chain) HandleRequest(ctx *Context, req *http.Request) (bool, error) {
for _, p := range c.plugins {
short, err := p.HandleRequest(ctx, req)
if err != nil {
return false, err
}
if short {
return true, nil
}
}
return false, nil
}
func (c *Chain) StoreRawResponses(ctx *Context, resp *BufferedResponse) {
for _, p := range c.plugins {
if st, ok := p.(RawStorer); ok {
st.StoreRaw(ctx, resp)
}
}
}
func (c *Chain) HandleResponse(ctx *Context, resp *BufferedResponse) error {
for _, p := range c.plugins {
if err := p.HandleResponse(ctx, resp); err != nil {
return err
}
}
return nil
}
+64
View File
@@ -0,0 +1,64 @@
package forwardplugin
import (
"encoding/json"
"fmt"
"net/http"
"strings"
)
func init() {
Register("inject_js", newInjectJSPlugin)
}
type injectJSConfig struct {
Script string `json:"script"`
}
type injectJSPlugin struct {
script string
}
func newInjectJSPlugin(raw json.RawMessage) (Plugin, error) {
var cfg injectJSConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
return nil, fmt.Errorf("inject_js 配置无效")
}
return &injectJSPlugin{script: cfg.Script}, nil
}
func (p *injectJSPlugin) Type() string { return "inject_js" }
func (p *injectJSPlugin) ValidateConfig(raw json.RawMessage) error {
var cfg injectJSConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
return fmt.Errorf("inject_js 配置无效")
}
if strings.TrimSpace(cfg.Script) == "" {
return fmt.Errorf("inject_js.script 不能为空")
}
return nil
}
func (p *injectJSPlugin) NeedsBody() bool { return true }
func (p *injectJSPlugin) HandleRequest(_ *Context, _ *http.Request) (bool, error) {
return false, nil
}
func (p *injectJSPlugin) HandleResponse(_ *Context, resp *BufferedResponse) error {
ct := strings.ToLower(resp.Header.Get("Content-Type"))
if !strings.Contains(ct, "text/html") {
return nil
}
body := string(resp.Body)
lower := strings.ToLower(body)
idx := strings.LastIndex(lower, "</body>")
if idx >= 0 {
body = body[:idx] + p.script + body[idx:]
} else {
body += p.script
}
resp.Body = []byte(body)
return nil
}
+93
View File
@@ -0,0 +1,93 @@
package forwardplugin
import (
"encoding/json"
"testing"
)
func TestValidateAndNormalizePlugins(t *testing.T) {
entries := []PluginEntry{
{
Type: "sub_filter",
Enabled: true,
Config: json.RawMessage(`{"filters":[{"from":"a","to":"b"}]}`),
},
{
Type: "response_cache",
Enabled: true,
Config: json.RawMessage(`{"max_mb":10,"ttl_sec":0}`),
},
}
out, err := ValidateAndNormalizePlugins(entries)
if err != nil {
t.Fatal(err)
}
if len(out) != 2 {
t.Fatalf("expected 2 plugins, got %d", len(out))
}
var cacheCfg responseCacheConfig
if err := json.Unmarshal(out[1].Config, &cacheCfg); err != nil {
t.Fatal(err)
}
if cacheCfg.TTLSec != 300 {
t.Fatalf("expected default ttl 300, got %d", cacheCfg.TTLSec)
}
}
func TestSubFilterReplace(t *testing.T) {
p, err := newSubFilterPlugin(json.RawMessage(`{"filters":[{"from":"old","to":"new"}],"types":["text/html"]}`))
if err != nil {
t.Fatal(err)
}
plugin := p.(*subFilterPlugin)
resp := &BufferedResponse{
StatusCode: 200,
Header: make(map[string][]string),
Body: []byte("<html>old page</html>"),
}
resp.Header.Set("Content-Type", "text/html; charset=utf-8")
if err := plugin.HandleResponse(nil, resp); err != nil {
t.Fatal(err)
}
if string(resp.Body) != "<html>new page</html>" {
t.Fatalf("unexpected body: %s", resp.Body)
}
}
func TestInjectJS(t *testing.T) {
p, err := newInjectJSPlugin(json.RawMessage(`{"script":"<script>x</script>"}`))
if err != nil {
t.Fatal(err)
}
plugin := p.(*injectJSPlugin)
resp := &BufferedResponse{
Header: make(map[string][]string),
Body: []byte("<html><body>hi</body></html>"),
}
resp.Header.Set("Content-Type", "text/html")
if err := plugin.HandleResponse(nil, resp); err != nil {
t.Fatal(err)
}
if want := "<html><body>hi<script>x</script></body></html>"; string(resp.Body) != want {
t.Fatalf("got %q want %q", resp.Body, want)
}
}
func TestCookieDomainRewrite(t *testing.T) {
p, err := newCookieDomainPlugin(json.RawMessage(`{"from":".old.com","to":".new.com"}`))
if err != nil {
t.Fatal(err)
}
plugin := p.(*cookieDomainPlugin)
resp := &BufferedResponse{
Header: make(map[string][]string),
}
resp.Header.Add("Set-Cookie", "sid=1; Domain=.old.com; Path=/")
if err := plugin.HandleResponse(nil, resp); err != nil {
t.Fatal(err)
}
got := resp.Header.Get("Set-Cookie")
if got != "sid=1; Domain=.new.com; Path=/" {
t.Fatalf("unexpected cookie: %s", got)
}
}
+228
View File
@@ -0,0 +1,228 @@
package forwardplugin
import (
"container/list"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
)
func init() {
Register("response_cache", newResponseCachePlugin)
}
type responseCacheConfig struct {
MaxMB int `json:"max_mb"`
TTLSec int `json:"ttl_sec"`
}
type responseCachePlugin struct {
forwardID uint
maxBytes int64
ttl time.Duration
store *responseCacheStore
}
func newResponseCachePlugin(raw json.RawMessage) (Plugin, error) {
var cfg responseCacheConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
return nil, fmt.Errorf("response_cache 配置无效")
}
return &responseCachePlugin{
maxBytes: int64(cfg.MaxMB) * 1024 * 1024,
ttl: time.Duration(cfg.TTLSec) * time.Second,
}, nil
}
func (p *responseCachePlugin) bindForwardID(id uint) {
p.forwardID = id
if p.maxBytes <= 0 {
return
}
p.store = globalResponseCaches.get(id, p.maxBytes, p.ttl)
}
func (p *responseCachePlugin) Type() string { return "response_cache" }
func (p *responseCachePlugin) ValidateConfig(raw json.RawMessage) error {
var cfg responseCacheConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
return fmt.Errorf("response_cache 配置无效")
}
if cfg.MaxMB < 0 {
return fmt.Errorf("response_cache.max_mb 不能为负数")
}
if cfg.MaxMB > 0 && cfg.TTLSec <= 0 {
return nil
}
if cfg.TTLSec < 0 {
return fmt.Errorf("response_cache.ttl_sec 不能为负数")
}
return nil
}
func (p *responseCachePlugin) NormalizeConfig(raw json.RawMessage) (json.RawMessage, error) {
var cfg responseCacheConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
return nil, err
}
if cfg.MaxMB > 0 && cfg.TTLSec <= 0 {
cfg.TTLSec = 300
}
return json.Marshal(cfg)
}
func (p *responseCachePlugin) NeedsBody() bool { return false }
func (p *responseCachePlugin) HandleRequest(ctx *Context, req *http.Request) (bool, error) {
if p.store == nil || req.Method != http.MethodGet {
return false, nil
}
key := cacheKey(req)
if hit := p.store.get(key); hit != nil {
ctx.CachedResp = hit.Clone()
ctx.CachedResp.FromCache = true
return true, nil
}
return false, nil
}
func (p *responseCachePlugin) HandleResponse(_ *Context, _ *BufferedResponse) error {
return nil
}
func (p *responseCachePlugin) StoreRaw(ctx *Context, resp *BufferedResponse) {
if p.store == nil || ctx.ClientReq.Method != http.MethodGet || resp.FromCache {
return
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return
}
if len(resp.Header.Values("Set-Cookie")) > 0 {
return
}
p.store.put(cacheKey(ctx.ClientReq), resp.Clone())
}
func cacheKey(req *http.Request) string {
if req.URL.RawQuery != "" {
return req.URL.Path + "?" + req.URL.RawQuery
}
return req.URL.Path
}
type cacheEntry struct {
key string
resp *BufferedResponse
expires time.Time
size int64
}
type responseCacheStore struct {
mu sync.Mutex
maxBytes int64
ttl time.Duration
usedBytes int64
items map[string]*list.Element
order *list.List
}
func newResponseCacheStore(maxBytes int64, ttl time.Duration) *responseCacheStore {
return &responseCacheStore{
maxBytes: maxBytes,
ttl: ttl,
items: make(map[string]*list.Element),
order: list.New(),
}
}
func (s *responseCacheStore) get(key string) *BufferedResponse {
s.mu.Lock()
defer s.mu.Unlock()
el, ok := s.items[key]
if !ok {
return nil
}
ent := el.Value.(*cacheEntry)
if time.Now().After(ent.expires) {
s.removeElement(el)
return nil
}
s.order.MoveToFront(el)
return ent.resp
}
func (s *responseCacheStore) put(key string, resp *BufferedResponse) {
size := int64(len(resp.Body))
for k, v := range resp.Header {
for _, vv := range v {
size += int64(len(k) + len(vv))
}
}
s.mu.Lock()
defer s.mu.Unlock()
if el, ok := s.items[key]; ok {
old := el.Value.(*cacheEntry)
s.usedBytes -= old.size
s.order.Remove(el)
delete(s.items, key)
}
for s.usedBytes+size > s.maxBytes && s.order.Len() > 0 {
s.removeElement(s.order.Back())
}
ent := &cacheEntry{
key: key,
resp: resp,
expires: time.Now().Add(s.ttl),
size: size,
}
el := s.order.PushFront(ent)
s.items[key] = el
s.usedBytes += size
}
func (s *responseCacheStore) removeElement(el *list.Element) {
ent := el.Value.(*cacheEntry)
delete(s.items, ent.key)
s.order.Remove(el)
s.usedBytes -= ent.size
}
type responseCacheManager struct {
mu sync.Mutex
caches map[uint]*responseCacheStore
}
var globalResponseCaches = &responseCacheManager{caches: make(map[uint]*responseCacheStore)}
func (m *responseCacheManager) get(forwardID uint, maxBytes int64, ttl time.Duration) *responseCacheStore {
m.mu.Lock()
defer m.mu.Unlock()
if c, ok := m.caches[forwardID]; ok {
c.maxBytes = maxBytes
c.ttl = ttl
return c
}
c := newResponseCacheStore(maxBytes, ttl)
m.caches[forwardID] = c
return c
}
func BindForwardID(chain *Chain, forwardID uint) {
if chain == nil {
return
}
for _, p := range chain.plugins {
if rc, ok := p.(*responseCachePlugin); ok {
rc.bindForwardID(forwardID)
}
}
}
func InvalidateForwardCache(forwardID uint) {
globalResponseCaches.mu.Lock()
defer globalResponseCaches.mu.Unlock()
delete(globalResponseCaches.caches, forwardID)
}
+117
View File
@@ -0,0 +1,117 @@
package forwardplugin
import (
"encoding/json"
"fmt"
"net/http"
"strings"
)
func init() {
Register("sub_filter", newSubFilterPlugin)
}
type subFilterConfig struct {
Filters []subFilterRule `json:"filters"`
Types []string `json:"types"`
}
type subFilterRule struct {
From string `json:"from"`
To string `json:"to"`
}
type subFilterPlugin struct {
filters []subFilterRule
types []string
}
func newSubFilterPlugin(raw json.RawMessage) (Plugin, error) {
var cfg subFilterConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
return nil, fmt.Errorf("sub_filter 配置无效")
}
return &subFilterPlugin{filters: cfg.Filters, types: cfg.Types}, nil
}
func (p *subFilterPlugin) Type() string { return "sub_filter" }
func (p *subFilterPlugin) ValidateConfig(raw json.RawMessage) error {
cfg, err := parseSubFilterConfig(raw)
if err != nil {
return err
}
if len(cfg.Filters) == 0 {
return fmt.Errorf("sub_filter.filters 不能为空")
}
for i, f := range cfg.Filters {
if f.From == "" {
return fmt.Errorf("sub_filter.filters[%d].from 不能为空", i)
}
}
return nil
}
func (p *subFilterPlugin) NormalizeConfig(raw json.RawMessage) (json.RawMessage, error) {
cfg, err := parseSubFilterConfig(raw)
if err != nil {
return nil, err
}
if len(cfg.Types) == 0 {
cfg.Types = []string{"text/html"}
}
return json.Marshal(cfg)
}
func parseSubFilterConfig(raw json.RawMessage) (*subFilterConfig, error) {
var cfg subFilterConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
return nil, fmt.Errorf("sub_filter 配置无效")
}
types := make([]string, 0, len(cfg.Types))
for _, t := range cfg.Types {
t = strings.TrimSpace(strings.ToLower(t))
if t != "" {
types = append(types, t)
}
}
if len(types) == 0 {
types = []string{"text/html"}
}
cfg.Types = types
return &cfg, nil
}
func (p *subFilterPlugin) NeedsBody() bool { return true }
func (p *subFilterPlugin) HandleRequest(_ *Context, _ *http.Request) (bool, error) {
return false, nil
}
func (p *subFilterPlugin) HandleResponse(_ *Context, resp *BufferedResponse) error {
if !p.matchesContentType(resp.Header.Get("Content-Type")) {
return nil
}
body := string(resp.Body)
for _, rule := range p.filters {
body = strings.ReplaceAll(body, rule.From, rule.To)
}
resp.Body = []byte(body)
return nil
}
func (p *subFilterPlugin) matchesContentType(ct string) bool {
ct = strings.TrimSpace(strings.ToLower(ct))
if ct == "" {
return false
}
if i := strings.Index(ct, ";"); i >= 0 {
ct = strings.TrimSpace(ct[:i])
}
for _, t := range p.types {
if ct == t {
return true
}
}
return false
}
+87
View File
@@ -0,0 +1,87 @@
package forwardplugin
import (
"encoding/json"
"fmt"
"strings"
)
func ValidateAndNormalizePlugins(entries []PluginEntry) ([]PluginEntry, error) {
if len(entries) == 0 {
return nil, nil
}
out := make([]PluginEntry, 0, len(entries))
for i, e := range entries {
typ := strings.TrimSpace(e.Type)
if typ == "" {
return nil, fmt.Errorf("plugins[%d].type 不能为空", i)
}
factory, ok := getFactory(typ)
if !ok {
return nil, fmt.Errorf("未知插件类型: %s", typ)
}
cfg := e.Config
if len(cfg) == 0 {
cfg = json.RawMessage("{}")
}
p, err := factory(cfg)
if err != nil {
return nil, fmt.Errorf("plugins[%d]: %w", i, err)
}
if err := p.ValidateConfig(cfg); err != nil {
return nil, fmt.Errorf("plugins[%d]: %w", i, err)
}
normalized, err := normalizeConfig(cfg, p)
if err != nil {
return nil, fmt.Errorf("plugins[%d]: %w", i, err)
}
out = append(out, PluginEntry{
Type: typ,
Enabled: e.Enabled,
Config: normalized,
})
}
return out, nil
}
func normalizeConfig(raw json.RawMessage, p Plugin) (json.RawMessage, error) {
if norm, ok := p.(ConfigNormalizer); ok {
return norm.NormalizeConfig(raw)
}
return raw, nil
}
type ConfigNormalizer interface {
NormalizeConfig(raw json.RawMessage) (json.RawMessage, error)
}
func BuildChain(entries []PluginEntry) (*Chain, error) {
plugins := make([]Plugin, 0, len(entries))
for _, e := range entries {
if !e.Enabled {
continue
}
factory, ok := getFactory(e.Type)
if !ok {
return nil, fmt.Errorf("未知插件类型: %s", e.Type)
}
cfg := e.Config
if len(cfg) == 0 {
cfg = json.RawMessage("{}")
}
p, err := factory(cfg)
if err != nil {
return nil, err
}
plugins = append(plugins, p)
}
return &Chain{plugins: plugins}, nil
}
func PluginsJSON(entries []PluginEntry) string {
if len(entries) == 0 {
return "[]"
}
b, _ := json.Marshal(entries)
return string(b)
}
+120
View File
@@ -0,0 +1,120 @@
package metrics
import (
"log"
"strconv"
"strings"
"time"
"github.com/shirou/gopsutil/v3/cpu"
"github.com/shirou/gopsutil/v3/load"
"github.com/shirou/gopsutil/v3/mem"
"github.com/wormhole/wormhole/internal/bridge"
"github.com/wormhole/wormhole/internal/cache"
"github.com/wormhole/wormhole/internal/store"
)
type Collector struct {
store *store.Store
cache *cache.Manager
bridge *bridge.Bridge
stop chan struct{}
}
func NewCollector(st *store.Store, c *cache.Manager, b *bridge.Bridge) *Collector {
return &Collector{
store: st,
cache: c,
bridge: b,
stop: make(chan struct{}),
}
}
func (c *Collector) Start(flushInterval time.Duration) {
go func() {
ticker := time.NewTicker(flushInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
c.flushFlow()
case <-c.stop:
return
}
}
}()
}
func (c *Collector) Stop() {
close(c.stop)
}
func (c *Collector) flushFlow() {
deltas := c.cache.DrainFlow()
for key, delta := range deltas {
parts := strings.SplitN(key, ":", 2)
if len(parts) != 2 {
continue
}
resourceType := parts[0]
resourceID, err := strconv.ParseUint(parts[1], 10, 64)
if err != nil {
continue
}
if err := c.store.UpsertFlowStat(resourceType, uint(resourceID), delta.Inlet, delta.Export); err != nil {
log.Printf("[metrics] flush flow stat: %v", err)
}
switch resourceType {
case "client":
_ = c.store.AddClientFlow(uint(resourceID), delta.Inlet, delta.Export)
case "tunnel":
_ = c.store.AddTunnelFlow(uint(resourceID), delta.Inlet, delta.Export)
case "host":
_ = c.store.AddHostFlow(uint(resourceID), delta.Inlet, delta.Export)
}
}
}
func (c *Collector) RecordRequestIP(resourceType string, resourceID uint, visitorIP, directIP string) {
if err := c.store.RecordRequestIP(resourceType, resourceID, visitorIP, directIP); err != nil {
log.Printf("[metrics] record request ip: %v", err)
}
}
func (c *Collector) AddFlow(resourceType string, resourceID uint, clientID uint, inlet, export int64) {
c.cache.AddFlow(resourceType, resourceID, inlet, export)
if clientID > 0 {
c.cache.AddFlow("client", clientID, inlet, export)
}
}
func (c *Collector) Dashboard() (map[string]interface{}, error) {
stats, err := c.store.DashboardStats()
if err != nil {
return nil, err
}
stats["client_online"] = c.bridge.OnlineCount()
cpuPercent, _ := cpu.Percent(0, false)
memInfo, _ := mem.VirtualMemory()
loadInfo, _ := load.Avg()
var cpuVal float64
if len(cpuPercent) > 0 {
cpuVal = cpuPercent[0]
}
topClients, _ := c.store.TopClientsByFlow(10)
topTunnels, _ := c.store.TopTunnelsByFlow(10)
topHosts, _ := c.store.TopHostsByFlow(10)
topIPs, _ := c.store.TopRequestIPs(10)
stats["cpu_percent"] = cpuVal
stats["mem_percent"] = memInfo.UsedPercent
stats["load1"] = loadInfo.Load1
stats["top_clients"] = topClients
stats["top_tunnels"] = topTunnels
stats["top_hosts"] = topHosts
stats["top_ips"] = topIPs
return stats, nil
}
+99
View File
@@ -0,0 +1,99 @@
package protocol
import (
"encoding/binary"
"encoding/json"
"fmt"
"io"
)
const (
MsgRegister byte = 1
MsgRegisterAck byte = 2
MsgPing byte = 3
MsgPong byte = 4
MsgOpenStream byte = 5
MsgStreamReady byte = 6
MsgClose byte = 7
)
const (
ConnTypeTCP = "tcp"
ConnTypeUDP = "udp"
ConnTypeHTTP = "http"
)
type RegisterPayload struct {
VerifyKey string `json:"verify_key"`
Version string `json:"version"`
}
type RegisterAckPayload struct {
ClientID uint `json:"client_id"`
Message string `json:"message"`
OK bool `json:"ok"`
}
type LinkMeta struct {
ConnType string `json:"conn_type"`
Target string `json:"target"`
RemoteAddr string `json:"remote_addr"`
StreamID uint32 `json:"stream_id"`
}
func WriteMessage(w io.Writer, msgType byte, payload interface{}) error {
var body []byte
var err error
if payload != nil {
body, err = json.Marshal(payload)
if err != nil {
return err
}
}
header := make([]byte, 5)
header[0] = msgType
binary.BigEndian.PutUint32(header[1:], uint32(len(body)))
if _, err := w.Write(header); err != nil {
return err
}
if len(body) > 0 {
_, err = w.Write(body)
}
return err
}
func ReadMessage(r io.Reader) (byte, []byte, error) {
header := make([]byte, 5)
if _, err := io.ReadFull(r, header); err != nil {
return 0, nil, err
}
msgType := header[0]
length := binary.BigEndian.Uint32(header[1:])
if length == 0 {
return msgType, nil, nil
}
if length > 1<<20 {
return 0, nil, fmt.Errorf("message too large: %d", length)
}
body := make([]byte, length)
if _, err := io.ReadFull(r, body); err != nil {
return 0, nil, err
}
return msgType, body, nil
}
func ParseRegister(body []byte) (*RegisterPayload, error) {
var p RegisterPayload
if err := json.Unmarshal(body, &p); err != nil {
return nil, err
}
return &p, nil
}
func ParseLinkMeta(body []byte) (*LinkMeta, error) {
var p LinkMeta
if err := json.Unmarshal(body, &p); err != nil {
return nil, err
}
return &p, nil
}
+217
View File
@@ -0,0 +1,217 @@
package proxy
import (
"context"
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"github.com/wormhole/wormhole/internal/store"
"golang.org/x/net/proxy"
)
func (m *Manager) handleDomainForward(w http.ResponseWriter, r *http.Request, df *store.DomainForward) {
switch df.Mode {
case "redirect":
m.handleDomainRedirect(w, r, df)
case "proxy":
m.handleDomainProxy(w, r, df)
default:
http.Error(w, "bad forward mode", http.StatusBadGateway)
}
}
func (m *Manager) handleDomainRedirect(w http.ResponseWriter, r *http.Request, df *store.DomainForward) {
target, err := buildRedirectURL(df, r)
if err != nil {
http.Error(w, "bad redirect target", http.StatusBadGateway)
return
}
code := df.RedirectCode
if code != 301 && code != 302 {
code = 302
}
http.Redirect(w, r, target, code)
}
func buildRedirectURL(df *store.DomainForward, r *http.Request) (string, error) {
targetBase, err := url.Parse(df.TargetURL)
if err != nil {
return "", err
}
if df.RedirectMode == "fixed" {
return targetBase.String(), nil
}
loc := df.SourceLocation
if loc == "" {
loc = "/"
}
suffix := strings.TrimPrefix(r.URL.Path, loc)
if suffix == "" {
suffix = "/"
}
if !strings.HasPrefix(suffix, "/") {
suffix = "/" + suffix
}
out := *targetBase
if strings.HasSuffix(targetBase.Path, "/") && suffix == "/" {
// keep target path as-is
} else if targetBase.Path == "" || targetBase.Path == "/" {
out.Path = suffix
} else {
out.Path = strings.TrimSuffix(targetBase.Path, "/") + suffix
}
out.RawQuery = r.URL.RawQuery
return out.String(), nil
}
func (m *Manager) handleDomainProxy(w http.ResponseWriter, r *http.Request, df *store.DomainForward) {
targetBase, err := url.Parse(df.TargetURL)
if err != nil {
http.Error(w, "bad target", http.StatusBadGateway)
return
}
transport, err := m.domainForwardTransport(df)
if err != nil {
http.Error(w, "proxy transport error", http.StatusBadGateway)
return
}
proxy := httputil.NewSingleHostReverseProxy(targetBase)
proxy.Transport = transport
loc := df.SourceLocation
if loc == "" {
loc = "/"
}
originalDirector := proxy.Director
proxy.Director = func(req *http.Request) {
originalDirector(req)
suffix := strings.TrimPrefix(r.URL.Path, loc)
if suffix == "" {
suffix = "/"
}
if !strings.HasPrefix(suffix, "/") {
suffix = "/" + suffix
}
if targetBase.Path == "" || targetBase.Path == "/" {
req.URL.Path = suffix
} else {
req.URL.Path = strings.TrimSuffix(targetBase.Path, "/") + suffix
}
req.URL.RawQuery = r.URL.RawQuery
req.URL.Scheme = targetBase.Scheme
req.URL.Host = targetBase.Host
if df.PreserveHost {
req.Host = r.Host
} else {
req.Host = targetBase.Host
}
}
proxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, e error) {
http.Error(rw, "proxy error", http.StatusBadGateway)
}
if isWebSocketUpgrade(r) {
proxy.ServeHTTP(w, r)
return
}
if hasEnabledPlugins(df.Plugins) {
upstreamReq, err := buildDomainForwardUpstreamRequest(r, df, targetBase, loc)
if err != nil {
http.Error(w, "bad target", http.StatusBadGateway)
return
}
if err := runForwardPluginPipeline(w, r, df, upstreamReq, transport); err != nil {
http.Error(w, "proxy error", http.StatusBadGateway)
}
return
}
rec := &responseRecorder{ResponseWriter: w, status: 200}
proxy.ServeHTTP(rec, r)
}
func buildDomainForwardUpstreamRequest(r *http.Request, df *store.DomainForward, targetBase *url.URL, loc string) (*http.Request, error) {
suffix := strings.TrimPrefix(r.URL.Path, loc)
if suffix == "" {
suffix = "/"
}
if !strings.HasPrefix(suffix, "/") {
suffix = "/" + suffix
}
path := suffix
if targetBase.Path != "" && targetBase.Path != "/" {
path = strings.TrimSuffix(targetBase.Path, "/") + suffix
}
upstreamURL := *targetBase
upstreamURL.Path = path
upstreamURL.RawQuery = r.URL.RawQuery
var body io.ReadCloser
if r.Body != nil && r.Method != http.MethodGet && r.Method != http.MethodHead {
body = r.Body
}
outReq, err := http.NewRequestWithContext(r.Context(), r.Method, upstreamURL.String(), body)
if err != nil {
return nil, err
}
outReq.Header = r.Header.Clone()
if df.PreserveHost {
outReq.Host = r.Host
} else {
outReq.Host = targetBase.Host
}
return outReq, nil
}
func (m *Manager) domainForwardTransport(df *store.DomainForward) (*http.Transport, error) {
t := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
if df.ProxyType == "http_proxy" {
addr := strings.TrimSpace(df.HTTPProxyAddr)
if addr == "" {
return nil, fmt.Errorf("http proxy addr required")
}
if !strings.Contains(addr, "://") {
addr = "http://" + addr
}
proxyURL, err := url.Parse(addr)
if err != nil || proxyURL.Host == "" {
return nil, fmt.Errorf("invalid http proxy addr")
}
if df.HTTPProxyUser != "" {
proxyURL.User = url.UserPassword(df.HTTPProxyUser, df.HTTPProxyPass)
}
t.Proxy = http.ProxyURL(proxyURL)
return t, nil
}
if df.ProxyType != "socks5" {
return t, nil
}
addr := strings.TrimSpace(df.Socks5Addr)
if addr == "" {
return nil, fmt.Errorf("socks5 addr required")
}
var auth *proxy.Auth
if df.Socks5User != "" {
auth = &proxy.Auth{User: df.Socks5User, Password: df.Socks5Pass}
}
dialer, err := proxy.SOCKS5("tcp", addr, auth, proxy.Direct)
if err != nil {
return nil, err
}
t.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
if cd, ok := dialer.(proxy.ContextDialer); ok {
return cd.DialContext(ctx, network, address)
}
return dialer.Dial(network, address)
}
return t, nil
}
+115
View File
@@ -0,0 +1,115 @@
package proxy
import (
"bytes"
"io"
"net/http"
"github.com/wormhole/wormhole/internal/forwardplugin"
"github.com/wormhole/wormhole/internal/store"
)
func pluginEntriesFromStore(plugins store.ForwardPlugins) []forwardplugin.PluginEntry {
if len(plugins) == 0 {
return nil
}
out := make([]forwardplugin.PluginEntry, len(plugins))
for i, p := range plugins {
out[i] = forwardplugin.PluginEntry{
Type: p.Type,
Enabled: p.Enabled,
Config: p.Config,
}
}
return out
}
func runForwardPluginPipeline(
w http.ResponseWriter,
clientReq *http.Request,
df *store.DomainForward,
upstreamReq *http.Request,
transport http.RoundTripper,
) error {
chain, err := forwardplugin.BuildChain(pluginEntriesFromStore(df.Plugins))
if err != nil {
return err
}
forwardplugin.BindForwardID(chain, df.ID)
ctx := &forwardplugin.Context{
ForwardID: df.ID,
ClientReq: clientReq,
}
outReq := upstreamReq.Clone(clientReq.Context())
if chain.NeedsBody() {
outReq.Header.Del("Accept-Encoding")
}
short, err := chain.HandleRequest(ctx, outReq)
if err != nil {
return err
}
var resp *forwardplugin.BufferedResponse
if short && ctx.CachedResp != nil {
resp = ctx.CachedResp.Clone()
} else {
resp, err = fetchUpstreamResponse(outReq, transport, chain.NeedsBody())
if err != nil {
return err
}
chain.StoreRawResponses(ctx, resp)
}
if err := chain.HandleResponse(ctx, resp); err != nil {
return err
}
writeBufferedResponse(w, resp)
return nil
}
func fetchUpstreamResponse(req *http.Request, transport http.RoundTripper, _ bool) (*forwardplugin.BufferedResponse, error) {
client := &http.Client{Transport: transport}
upstreamResp, err := client.Do(req)
if err != nil {
return nil, err
}
defer upstreamResp.Body.Close()
body, err := io.ReadAll(upstreamResp.Body)
if err != nil {
return nil, err
}
return &forwardplugin.BufferedResponse{
StatusCode: upstreamResp.StatusCode,
Header: upstreamResp.Header.Clone(),
Body: body,
}, nil
}
func writeBufferedResponse(w http.ResponseWriter, resp *forwardplugin.BufferedResponse) {
for k, vals := range resp.Header {
for _, v := range vals {
w.Header().Add(k, v)
}
}
if resp.StatusCode == 0 {
resp.StatusCode = http.StatusOK
}
w.WriteHeader(resp.StatusCode)
if len(resp.Body) > 0 {
_, _ = io.Copy(w, bytes.NewReader(resp.Body))
}
}
func hasEnabledPlugins(plugins store.ForwardPlugins) bool {
for _, p := range plugins {
if p.Enabled {
return true
}
}
return false
}
+152
View File
@@ -0,0 +1,152 @@
package proxy
import (
"encoding/json"
"net"
"net/http"
"strings"
"github.com/wormhole/wormhole/internal/auth"
"github.com/wormhole/wormhole/internal/store"
)
const (
// IPForwardReplace 覆盖 X-Forwarded-For / X-Real-IP 为访客真实 IP。
IPForwardReplace = "replace"
// IPForwardAppend 按反向代理规范追加 X-Forwarded-ForNginx proxy_add_x_forwarded_for)。
IPForwardAppend = "append"
)
func normalizeIPForwardMode(mode string) string {
switch mode {
case IPForwardReplace, IPForwardAppend:
return mode
case "real": // 兼容旧值
return IPForwardReplace
case "proxy": // 兼容旧值
return IPForwardAppend
default:
return IPForwardReplace
}
}
func parseHeaderMap(raw string) map[string]string {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "{}" {
return nil
}
var m map[string]string
if err := json.Unmarshal([]byte(raw), &m); err != nil {
return nil
}
return m
}
func mergeHeaderMaps(base, override map[string]string) map[string]string {
out := make(map[string]string)
for k, v := range base {
out[k] = v
}
for k, v := range override {
out[k] = v
}
if len(out) == 0 {
return nil
}
return out
}
func effectiveIPMode(resourceMode, clientMode string) string {
if resourceMode != "" {
return normalizeIPForwardMode(resourceMode)
}
if clientMode != "" {
return normalizeIPForwardMode(clientMode)
}
return IPForwardReplace
}
func resolveProxyConfig(st *store.Store, clientID uint, resourceMode, resourceHeaders string) (mode string, headers map[string]string) {
client, _ := st.GetClientByID(clientID)
clientMode := ""
clientHeaders := ""
if client != nil {
clientMode = client.IPForwardMode
clientHeaders = client.CustomHeaders
}
mode = effectiveIPMode(resourceMode, clientMode)
headers = mergeHeaderMaps(parseHeaderMap(clientHeaders), parseHeaderMap(resourceHeaders))
return mode, headers
}
func hostOnly(addr string) string {
if addr == "" {
return ""
}
host, _, err := net.SplitHostPort(addr)
if err != nil {
return strings.Trim(addr, "[]")
}
return host
}
func directClientIP(r *http.Request) string {
return hostOnly(r.RemoteAddr)
}
func forwardIPForHTTP(r *http.Request, mode string) string {
mode = normalizeIPForwardMode(mode)
if mode == IPForwardAppend {
return directClientIP(r)
}
return auth.ExtractRemoteIP(r)
}
func forwardIPForTCP(clientAddr string, _ string) string {
return hostOnly(clientAddr)
}
func isForbiddenHeader(name string) bool {
switch strings.ToLower(name) {
case "connection", "transfer-encoding", "keep-alive", "proxy-connection",
"te", "trailer", "upgrade", "content-length", "host":
return true
default:
return false
}
}
func applyOutboundHeaders(req *http.Request, r *http.Request, mode string, custom map[string]string) {
mode = normalizeIPForwardMode(mode)
req.Header.Del("X-Forwarded-For")
req.Header.Del("X-Real-IP")
req.Header.Del("Forwarded")
switch mode {
case IPForwardAppend:
clientIP := directClientIP(r)
if clientIP == "" {
break
}
existing := strings.TrimSpace(r.Header.Get("X-Forwarded-For"))
xff := clientIP
if existing != "" {
xff = existing + ", " + clientIP
}
req.Header.Set("X-Forwarded-For", xff)
req.Header.Set("X-Real-IP", clientIP)
default:
visitorIP := auth.ExtractRemoteIP(r)
if visitorIP != "" {
req.Header.Set("X-Forwarded-For", visitorIP)
req.Header.Set("X-Real-IP", visitorIP)
}
}
for k, v := range custom {
if k == "" || isForbiddenHeader(k) {
continue
}
req.Header.Set(k, v)
}
}
+74
View File
@@ -0,0 +1,74 @@
package proxy
import (
"context"
"log"
"net"
"net/http"
"sync"
"time"
"github.com/wormhole/wormhole/internal/auth"
"github.com/wormhole/wormhole/internal/store"
)
type httpTunnelServer struct {
manager *Manager
tunnel *store.Tunnel
srv *http.Server
stop chan struct{}
wg sync.WaitGroup
}
func newHTTPTunnelServer(m *Manager, t *store.Tunnel) Service {
return &httpTunnelServer{
manager: m,
tunnel: t,
stop: make(chan struct{}),
}
}
func (s *httpTunnelServer) ID() uint { return s.tunnel.ID }
func (s *httpTunnelServer) Start() error {
addr := net.JoinHostPort("0.0.0.0", itoa(s.tunnel.ListenPort))
s.srv = &http.Server{
Addr: addr,
Handler: http.HandlerFunc(s.handleHTTP),
}
log.Printf("[proxy] http tunnel %d (visitor auth) listening on %s -> %s", s.tunnel.ID, addr, s.tunnel.TargetAddr)
s.wg.Add(1)
go func() {
defer s.wg.Done()
if err := s.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("[proxy] http tunnel %d error: %v", s.tunnel.ID, err)
}
}()
return nil
}
func (s *httpTunnelServer) handleHTTP(w http.ResponseWriter, r *http.Request) {
t := s.tunnel
ip := auth.ExtractRemoteIP(r)
directIP := auth.DirectRemoteIP(r)
s.manager.metrics.RecordRequestIP("tunnel", t.ID, ip, directIP)
if !s.manager.security.Allow(directIP, t.ClientID, "tunnel", t.ID) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if !s.manager.resAuth.CheckHTTP(w, r, "tunnel", t.ID) {
return
}
s.manager.proxyTunnelHTTP(w, r, t)
}
func (s *httpTunnelServer) Stop() error {
close(s.stop)
if s.srv != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = s.srv.Shutdown(ctx)
}
s.wg.Wait()
return nil
}
+349
View File
@@ -0,0 +1,349 @@
package proxy
import (
"bufio"
"context"
"crypto/tls"
"fmt"
"log"
"net"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"sync"
"github.com/wormhole/wormhole/internal/auth"
"github.com/wormhole/wormhole/internal/bridge"
"github.com/wormhole/wormhole/internal/cache"
"github.com/wormhole/wormhole/internal/metrics"
"github.com/wormhole/wormhole/internal/protocol"
"github.com/wormhole/wormhole/internal/security"
"github.com/wormhole/wormhole/internal/store"
)
type Manager struct {
cache *cache.Manager
store *store.Store
bridge *bridge.Bridge
security *security.Service
resAuth *auth.ResourceAuth
metrics *metrics.Collector
mu sync.Mutex
services map[uint]Service
httpProxySrv *http.Server
httpsProxySrv *http.Server
}
type Service interface {
Start() error
Stop() error
ID() uint
}
func NewManager(c *cache.Manager, st *store.Store, b *bridge.Bridge, sec *security.Service, ra *auth.ResourceAuth, mc *metrics.Collector) *Manager {
return &Manager{
cache: c,
store: st,
bridge: b,
security: sec,
resAuth: ra,
metrics: mc,
services: make(map[uint]Service),
}
}
func (m *Manager) StartAll() error {
tunnels := m.cache.ListEnabledTunnels()
for _, t := range tunnels {
if err := m.StartTunnel(t.ID); err != nil {
log.Printf("[proxy] start tunnel %d: %v", t.ID, err)
}
}
return nil
}
func (m *Manager) StartTunnel(id uint) error {
t, ok := m.cache.GetTunnel(id)
if !ok {
return nil
}
m.mu.Lock()
defer m.mu.Unlock()
if old, exists := m.services[id]; exists {
_ = old.Stop()
delete(m.services, id)
}
var svc Service
switch t.Mode {
case "udp":
svc = newUDPServer(m, t)
default:
if t.VisitorAuthEnabled {
svc = newHTTPTunnelServer(m, t)
} else {
svc = newTCPServer(m, t)
}
}
if err := svc.Start(); err != nil {
return err
}
m.services[id] = svc
t.RunStatus = true
_ = m.store.UpdateTunnel(t)
m.cache.SetTunnelRunStatus(id, true)
return nil
}
func (m *Manager) StopTunnel(id uint) error {
m.mu.Lock()
defer m.mu.Unlock()
if svc, ok := m.services[id]; ok {
_ = svc.Stop()
delete(m.services, id)
}
if t, ok := m.cache.GetTunnel(id); ok {
t.RunStatus = false
_ = m.store.UpdateTunnel(t)
m.cache.SetTunnelRunStatus(id, false)
}
return nil
}
func (m *Manager) StartHTTPServer(httpPort, httpsPort int, certFile, keyFile string) error {
if httpPort > 0 {
srv := &http.Server{
Addr: formatPort(httpPort),
Handler: m.httpHandler("http"),
}
m.httpProxySrv = srv
go func() {
log.Printf("[proxy] http host server on %s", srv.Addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("[proxy] http server error: %v", err)
}
}()
}
if httpsPort > 0 {
srv := &http.Server{
Addr: formatPort(httpsPort),
Handler: m.httpHandler("https"),
}
m.httpsProxySrv = srv
go func() {
log.Printf("[proxy] https host server on %s", srv.Addr)
if certFile != "" && keyFile != "" {
if err := srv.ListenAndServeTLS(certFile, keyFile); err != nil && err != http.ErrServerClosed {
log.Printf("[proxy] https server error: %v", err)
}
} else {
log.Printf("[proxy] https port enabled but tls cert/key not configured, skipping TLS listener")
}
}()
}
return nil
}
func (m *Manager) Shutdown(ctx context.Context) error {
m.mu.Lock()
services := make([]Service, 0, len(m.services))
for _, svc := range m.services {
services = append(services, svc)
}
m.mu.Unlock()
for _, svc := range services {
_ = svc.Stop()
}
var err error
if m.httpProxySrv != nil {
if e := m.httpProxySrv.Shutdown(ctx); e != nil {
err = e
}
}
if m.httpsProxySrv != nil {
if e := m.httpsProxySrv.Shutdown(ctx); e != nil {
err = e
}
}
return err
}
func formatPort(port int) string {
return net.JoinHostPort("", itoa(port))
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b [12]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
func (m *Manager) httpHandler(scheme string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
directIP := auth.DirectRemoteIP(r)
if fwd := m.cache.MatchDomainForward(r.Host, r.URL.Path, scheme); fwd != nil {
if !m.security.Allow(directIP, 0, "global", 0) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
m.handleDomainForward(w, r, fwd)
return
}
host := m.cache.MatchHost(r.Host, r.URL.Path, scheme)
if host == nil {
http.Error(w, "host not found", http.StatusNotFound)
return
}
ip := auth.ExtractRemoteIP(r)
m.metrics.RecordRequestIP("host", host.ID, ip, directIP)
if !m.security.Allow(directIP, host.ClientID, "host", host.ID) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if !m.resAuth.CheckHTTP(w, r, "host", host.ID) {
return
}
if host.AutoHTTPS && scheme == "http" {
target := "https://" + r.Host + r.URL.RequestURI()
http.Redirect(w, r, target, http.StatusMovedPermanently)
return
}
m.proxyHTTP(w, r, host, "host")
})
}
func (m *Manager) proxyHTTP(w http.ResponseWriter, r *http.Request, host *store.Host, resourceType string) {
targetURL, err := url.Parse("http://" + host.TargetAddr)
if err != nil {
http.Error(w, "bad target", http.StatusBadGateway)
return
}
if !m.bridge.IsOnline(host.ClientID) {
http.Error(w, "client offline", http.StatusBadGateway)
return
}
mode, headers := resolveProxyConfig(m.store, host.ClientID, host.IPForwardMode, host.CustomHeaders)
forwardIP := forwardIPForHTTP(r, mode)
meta := &protocol.LinkMeta{
ConnType: protocol.ConnTypeHTTP,
Target: host.TargetAddr,
RemoteAddr: forwardIP,
}
stream, err := m.bridge.OpenStream(host.ClientID, meta)
if err != nil {
http.Error(w, "tunnel error", http.StatusBadGateway)
return
}
defer stream.Close()
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return stream, nil
},
}
proxy := httputil.NewSingleHostReverseProxy(targetURL)
proxy.Transport = transport
originalDirector := proxy.Director
proxy.Director = func(req *http.Request) {
originalDirector(req)
req.Host = targetURL.Host
req.URL.Host = targetURL.Host
req.URL.Scheme = targetURL.Scheme
applyOutboundHeaders(req, r, mode, headers)
}
proxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, e error) {
http.Error(rw, "proxy error", http.StatusBadGateway)
}
if isWebSocketUpgrade(r) {
proxy.ServeHTTP(w, r)
return
}
rec := &responseRecorder{ResponseWriter: w, status: 200}
proxy.ServeHTTP(rec, r)
inlet := int64(rec.written)
export := int64(r.ContentLength)
if export < 0 {
export = 0
}
m.metrics.AddFlow(resourceType, host.ID, host.ClientID, inlet, export)
}
func (m *Manager) proxyTunnelHTTP(w http.ResponseWriter, r *http.Request, t *store.Tunnel) {
host := &store.Host{
ID: t.ID,
ClientID: t.ClientID,
TargetAddr: t.TargetAddr,
IPForwardMode: t.IPForwardMode,
CustomHeaders: t.CustomHeaders,
}
m.proxyHTTP(w, r, host, "tunnel")
}
type responseRecorder struct {
http.ResponseWriter
status int
written int
}
func (r *responseRecorder) Write(b []byte) (int, error) {
n, err := r.ResponseWriter.Write(b)
r.written += n
return n, err
}
func (r *responseRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if h, ok := r.ResponseWriter.(http.Hijacker); ok {
return h.Hijack()
}
return nil, nil, fmt.Errorf("hijack not supported")
}
func (r *responseRecorder) Flush() {
if f, ok := r.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func isWebSocketUpgrade(r *http.Request) bool {
return strings.EqualFold(r.Header.Get("Upgrade"), "websocket")
}
func (m *Manager) handleTCPConnection(t *store.Tunnel, clientConn net.Conn) {
defer clientConn.Close()
ip := auth.AddrHost(clientConn.RemoteAddr())
m.metrics.RecordRequestIP("tunnel", t.ID, ip, ip)
if !m.security.Allow(ip, t.ClientID, "tunnel", t.ID) {
return
}
if !m.resAuth.CheckTCP(clientConn.RemoteAddr(), "tunnel", t.ID) {
return
}
if !m.bridge.IsOnline(t.ClientID) {
return
}
mode, _ := resolveProxyConfig(m.store, t.ClientID, t.IPForwardMode, t.CustomHeaders)
meta := &protocol.LinkMeta{
ConnType: protocol.ConnTypeTCP,
Target: t.TargetAddr,
RemoteAddr: forwardIPForTCP(clientConn.RemoteAddr().String(), mode),
}
stream, err := m.bridge.OpenStream(t.ClientID, meta)
if err != nil {
return
}
defer stream.Close()
inlet, export := bridge.CopyBoth(clientConn, stream)
m.metrics.AddFlow("tunnel", t.ID, t.ClientID, inlet, export)
}
+216
View File
@@ -0,0 +1,216 @@
package proxy
import (
"log"
"net"
"sync"
"time"
"github.com/wormhole/wormhole/internal/protocol"
"github.com/wormhole/wormhole/internal/store"
)
type tcpServer struct {
manager *Manager
tunnel *store.Tunnel
ln net.Listener
stop chan struct{}
wg sync.WaitGroup
}
func newTCPServer(m *Manager, t *store.Tunnel) Service {
return &tcpServer{
manager: m,
tunnel: t,
stop: make(chan struct{}),
}
}
func (s *tcpServer) ID() uint { return s.tunnel.ID }
func (s *tcpServer) Start() error {
addr := net.JoinHostPort("0.0.0.0", itoa(s.tunnel.ListenPort))
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
s.ln = ln
log.Printf("[proxy] tcp tunnel %d listening on %s -> %s", s.tunnel.ID, addr, s.tunnel.TargetAddr)
go s.acceptLoop()
return nil
}
func (s *tcpServer) acceptLoop() {
for {
conn, err := s.ln.Accept()
if err != nil {
select {
case <-s.stop:
return
default:
continue
}
}
s.wg.Add(1)
go func(c net.Conn) {
defer s.wg.Done()
s.manager.handleTCPConnection(s.tunnel, c)
}(conn)
}
}
func (s *tcpServer) Stop() error {
close(s.stop)
if s.ln != nil {
_ = s.ln.Close()
}
s.wg.Wait()
return nil
}
type udpSession struct {
stream net.Conn
last time.Time
}
type udpServer struct {
manager *Manager
tunnel *store.Tunnel
conn *net.UDPConn
stop chan struct{}
mu sync.Mutex
sessions map[string]*udpSession
}
func newUDPServer(m *Manager, t *store.Tunnel) Service {
return &udpServer{
manager: m,
tunnel: t,
stop: make(chan struct{}),
sessions: make(map[string]*udpSession),
}
}
func (s *udpServer) ID() uint { return s.tunnel.ID }
func (s *udpServer) Start() error {
addr := net.JoinHostPort("0.0.0.0", itoa(s.tunnel.ListenPort))
udpAddr, err := net.ResolveUDPAddr("udp", addr)
if err != nil {
return err
}
conn, err := net.ListenUDP("udp", udpAddr)
if err != nil {
return err
}
s.conn = conn
log.Printf("[proxy] udp tunnel %d listening on %s -> %s", s.tunnel.ID, addr, s.tunnel.TargetAddr)
go s.readLoop()
go s.sweeper()
return nil
}
func (s *udpServer) readLoop() {
buf := make([]byte, 65535)
for {
n, remote, err := s.conn.ReadFromUDP(buf)
if err != nil {
select {
case <-s.stop:
return
default:
continue
}
}
ip := remote.IP.String()
s.manager.metrics.RecordRequestIP("tunnel", s.tunnel.ID, ip, ip)
if !s.manager.security.Allow(ip, s.tunnel.ClientID, "tunnel", s.tunnel.ID) {
continue
}
key := remote.String()
s.mu.Lock()
sess, ok := s.sessions[key]
if !ok {
sess = s.createSession(remote)
if sess == nil {
s.mu.Unlock()
continue
}
s.sessions[key] = sess
go s.runSession(key, sess, remote)
}
sess.last = time.Now()
stream := sess.stream
s.mu.Unlock()
_, _ = stream.Write(buf[:n])
}
}
func (s *udpServer) createSession(remote *net.UDPAddr) *udpSession {
if !s.manager.bridge.IsOnline(s.tunnel.ClientID) {
return nil
}
mode, _ := resolveProxyConfig(s.manager.store, s.tunnel.ClientID, s.tunnel.IPForwardMode, s.tunnel.CustomHeaders)
meta := &protocol.LinkMeta{
ConnType: protocol.ConnTypeUDP,
Target: s.tunnel.TargetAddr,
RemoteAddr: forwardIPForTCP(remote.String(), mode),
}
stream, err := s.manager.bridge.OpenStream(s.tunnel.ClientID, meta)
if err != nil {
return nil
}
return &udpSession{stream: stream, last: time.Now()}
}
func (s *udpServer) runSession(key string, sess *udpSession, remote *net.UDPAddr) {
buf := make([]byte, 65535)
for {
n, err := sess.stream.Read(buf)
if err != nil {
break
}
_, _ = s.conn.WriteToUDP(buf[:n], remote)
inlet := int64(n)
s.manager.metrics.AddFlow("tunnel", s.tunnel.ID, s.tunnel.ClientID, inlet, 0)
}
s.mu.Lock()
delete(s.sessions, key)
s.mu.Unlock()
_ = sess.stream.Close()
}
func (s *udpServer) sweeper() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
now := time.Now()
s.mu.Lock()
for k, sess := range s.sessions {
if now.Sub(sess.last) > 120*time.Second {
_ = sess.stream.Close()
delete(s.sessions, k)
}
}
s.mu.Unlock()
case <-s.stop:
return
}
}
}
func (s *udpServer) Stop() error {
close(s.stop)
if s.conn != nil {
_ = s.conn.Close()
}
s.mu.Lock()
for _, sess := range s.sessions {
_ = sess.stream.Close()
}
s.sessions = make(map[string]*udpSession)
s.mu.Unlock()
return nil
}
+64
View File
@@ -0,0 +1,64 @@
package security
import (
"net"
"github.com/wormhole/wormhole/internal/cache"
"github.com/wormhole/wormhole/internal/store"
)
type Service struct {
cache *cache.Manager
store *store.Store
}
func NewService(c *cache.Manager, st *store.Store) *Service {
return &Service{cache: c, store: st}
}
func (s *Service) Allow(ip string, clientID uint, resourceType string, resourceID uint) bool {
scopes := []cache.Scope{
{Kind: "global", ResourceID: 0},
{Kind: "client", ResourceID: clientID},
}
if resourceType == "host" || resourceType == "tunnel" {
scopes = append(scopes, cache.Scope{Kind: resourceType, ResourceID: resourceID})
}
return s.cache.AllowIP(ip, scopes)
}
func (s *Service) BlockIP(ip, scope string, resourceID uint, remark string) error {
rule := &store.IPRule{
Scope: scope,
ResourceID: resourceID,
Type: "deny",
Pattern: ip,
PatternKind: "exact",
Priority: 100,
Remark: remark,
}
if err := s.store.CreateIPRule(rule); err != nil {
return err
}
return s.cache.ReloadIPRules(s.store)
}
func DetectPatternKind(pattern string) string {
if _, _, err := net.ParseCIDR(pattern); err == nil {
return "cidr"
}
if len(pattern) > 0 && (pattern[0] == '^' || pattern[len(pattern)-1] == '$' || containsMeta(pattern)) {
return "regex"
}
return "exact"
}
func containsMeta(s string) bool {
for _, c := range s {
switch c {
case '.', '*', '+', '?', '[', ']', '(', ')', '|', '\\':
return true
}
}
return false
}
+120
View File
@@ -0,0 +1,120 @@
package server
import (
"context"
"flag"
"io/fs"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/wormhole/wormhole/internal/api"
"github.com/wormhole/wormhole/internal/auth"
"github.com/wormhole/wormhole/internal/bridge"
"github.com/wormhole/wormhole/internal/cache"
"github.com/wormhole/wormhole/internal/config"
"github.com/wormhole/wormhole/internal/metrics"
"github.com/wormhole/wormhole/internal/proxy"
"github.com/wormhole/wormhole/internal/security"
"github.com/wormhole/wormhole/internal/store"
webdist "github.com/wormhole/wormhole/web"
)
func Run(args []string) {
fs := flag.NewFlagSet("server", flag.ExitOnError)
fs.Parse(args)
st, err := store.Open(config.DefaultDBPath)
if err != nil {
log.Fatalf("open store: %v", err)
}
cfg, err := st.LoadConfig()
if err != nil {
log.Fatalf("load settings: %v", err)
}
log.Printf("[server] settings loaded (%s)", cfg.Summary())
c := cache.NewManager()
if err := c.ReloadAll(st); err != nil {
log.Fatalf("reload cache: %v", err)
}
authSvc := auth.NewService(&cfg.Auth)
resAuth := auth.NewResourceAuth(c, st, &cfg.Auth)
sec := security.NewService(c, st)
b := bridge.New(c, st)
mc := metrics.NewCollector(st, c, b)
pm := proxy.NewManager(c, st, b, sec, resAuth, mc)
if err := b.Start(cfg.Server.BridgeAddr, cfg.Server.TLSCertFile, cfg.Server.TLSKeyFile); err != nil {
log.Fatalf("bridge start: %v", err)
}
mc.Start(cfg.Metrics.FlushInterval)
if err := pm.StartAll(); err != nil {
log.Printf("start tunnels: %v", err)
}
if err := pm.StartHTTPServer(cfg.Server.HTTPProxyPort, cfg.Server.HTTPSProxyPort, cfg.Server.TLSCertFile, cfg.Server.TLSKeyFile); err != nil {
log.Printf("start http proxy: %v", err)
}
apiServer := api.NewServer(cfg, st, c, authSvc, resAuth, sec, b, pm, mc)
router := apiServer.Router()
router.NoRoute(serveSPA())
srv := &http.Server{
Addr: cfg.Server.HTTPAddr,
Handler: router,
}
go func() {
log.Printf("[server] http api on %s", cfg.Server.HTTPAddr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("http server: %v", err)
}
}()
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
log.Println("[server] shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
mc.Stop()
if err := pm.Shutdown(ctx); err != nil {
log.Printf("[server] proxy shutdown: %v", err)
}
_ = b.Shutdown()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("[server] api shutdown: %v", err)
}
log.Println("[server] stopped")
}
func serveSPA() gin.HandlerFunc {
distFS, err := fs.Sub(webdist.Dist, "dist")
if err != nil {
return func(c *gin.Context) {
c.String(http.StatusOK, "Wormhole API running. Build web dist for UI.")
}
}
fileServer := http.FileServer(http.FS(distFS))
return func(c *gin.Context) {
path := c.Request.URL.Path
if path == "/" {
path = "/index.html"
}
if f, err := distFS.Open(path[1:]); err == nil {
_ = f.Close()
fileServer.ServeHTTP(c.Writer, c.Request)
return
}
c.Request.URL.Path = "/"
fileServer.ServeHTTP(c.Writer, c.Request)
}
}
+87
View File
@@ -0,0 +1,87 @@
package store
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
)
func hashAPIKey(key string) string {
sum := sha256.Sum256([]byte(key))
return hex.EncodeToString(sum[:])
}
func generateAPIKeyPlain() (string, string, error) {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "", "", err
}
plain := "wh_" + hex.EncodeToString(b)
prefix := plain
if len(prefix) > 12 {
prefix = prefix[:12]
}
return plain, prefix, nil
}
func (s *Store) ListAPIKeys(userID uint, isAdmin bool) ([]APIKey, error) {
var keys []APIKey
q := s.db.Order("id asc")
if !isAdmin {
q = q.Where("user_id = ?", userID)
}
return keys, q.Find(&keys).Error
}
func (s *Store) CreateAPIKey(name string, userID uint) (*APIKey, string, error) {
if name == "" {
return nil, "", errors.New("name required")
}
plain, prefix, err := generateAPIKeyPlain()
if err != nil {
return nil, "", err
}
key := &APIKey{
Name: name,
KeyPrefix: prefix,
KeyHash: hashAPIKey(plain),
UserID: userID,
Status: true,
}
if err := s.db.Create(key).Error; err != nil {
return nil, "", err
}
return key, plain, nil
}
func (s *Store) DeleteAPIKey(id uint, userID uint, isAdmin bool) error {
var key APIKey
if err := s.db.First(&key, id).Error; err != nil {
return err
}
if !isAdmin && key.UserID != userID {
return fmt.Errorf("permission denied")
}
return s.db.Delete(&APIKey{}, id).Error
}
func (s *Store) ValidateAPIKey(plain string) (*User, error) {
if plain == "" {
return nil, errors.New("empty api key")
}
hash := hashAPIKey(plain)
var key APIKey
if err := s.db.Where("key_hash = ? AND status = ?", hash, true).First(&key).Error; err != nil {
return nil, errors.New("invalid api key")
}
user, err := s.GetUserByID(key.UserID)
if err != nil {
return nil, err
}
if !user.Status {
return nil, errors.New("user disabled")
}
return user, nil
}
+176
View File
@@ -0,0 +1,176 @@
package store
import (
"fmt"
"net/url"
"strings"
"github.com/wormhole/wormhole/internal/forwardplugin"
)
func normalizeDomainForwardRoute(host, location string) (string, string) {
host = strings.TrimSpace(strings.ToLower(host))
location = strings.TrimSpace(location)
if location == "" {
location = "/"
}
if !strings.HasPrefix(location, "/") {
location = "/" + location
}
return host, location
}
func (s *Store) ListDomainForwards() ([]DomainForward, error) {
var items []DomainForward
err := s.db.Order("id asc").Find(&items).Error
return items, err
}
func (s *Store) GetDomainForwardByID(id uint) (*DomainForward, error) {
var df DomainForward
if err := s.db.First(&df, id).Error; err != nil {
return nil, err
}
return &df, nil
}
func (s *Store) CreateDomainForward(df *DomainForward) error {
df.SourceHost, df.SourceLocation = normalizeDomainForwardRoute(df.SourceHost, df.SourceLocation)
return s.db.Create(df).Error
}
func (s *Store) UpdateDomainForward(df *DomainForward) error {
df.SourceHost, df.SourceLocation = normalizeDomainForwardRoute(df.SourceHost, df.SourceLocation)
return s.db.Save(df).Error
}
func (s *Store) DeleteDomainForward(id uint) error {
return s.db.Delete(&DomainForward{}, id).Error
}
func (s *Store) DomainForwardRouteInUse(host, location string, excludeID uint) (bool, error) {
host, location = normalizeDomainForwardRoute(host, location)
q := s.db.Model(&DomainForward{}).Where("source_host = ? AND source_location = ?", host, location)
if excludeID > 0 {
q = q.Where("id <> ?", excludeID)
}
var count int64
if err := q.Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
// RouteConflict checks domain forward vs penetration host on same host+location.
func (s *Store) RouteConflict(host, location string, excludeForwardID, excludeHostID uint) error {
host, location = normalizeDomainForwardRoute(host, location)
inUse, err := s.DomainForwardRouteInUse(host, location, excludeForwardID)
if err != nil {
return err
}
if inUse {
return fmt.Errorf("域名与路径前缀已被其他转发规则占用")
}
hostInUse, err := s.HostRouteInUse(host, location, excludeHostID)
if err != nil {
return err
}
if hostInUse {
return fmt.Errorf("域名与路径前缀已被穿透域名占用")
}
return nil
}
func ValidateDomainForward(df *DomainForward) error {
if strings.TrimSpace(df.SourceHost) == "" {
return fmt.Errorf("源域名不能为空")
}
target := strings.TrimSpace(df.TargetURL)
if target == "" {
return fmt.Errorf("目标 URL 不能为空")
}
u, err := url.Parse(target)
if err != nil || u.Scheme == "" || u.Host == "" {
return fmt.Errorf("目标 URL 格式无效,需包含协议和主机,如 https://example.com")
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("目标 URL 仅支持 http/https")
}
df.TargetURL = u.String()
switch df.Mode {
case "redirect", "proxy":
default:
return fmt.Errorf("模式无效")
}
if df.Mode == "redirect" {
if df.RedirectMode == "" {
df.RedirectMode = "preserve"
}
if df.RedirectMode != "preserve" && df.RedirectMode != "fixed" {
return fmt.Errorf("跳转策略无效")
}
if df.RedirectCode == 0 {
df.RedirectCode = 302
}
if df.RedirectCode != 301 && df.RedirectCode != 302 {
return fmt.Errorf("跳转状态码仅支持 301 或 302")
}
}
if df.Mode == "proxy" {
if df.ProxyType == "" {
df.ProxyType = "http"
}
if df.ProxyType != "http" && df.ProxyType != "socks5" && df.ProxyType != "http_proxy" {
return fmt.Errorf("代理类型无效")
}
if df.ProxyType == "socks5" && strings.TrimSpace(df.Socks5Addr) == "" {
return fmt.Errorf("SOCKS5 代理地址不能为空")
}
if df.ProxyType == "http_proxy" && strings.TrimSpace(df.HTTPProxyAddr) == "" {
return fmt.Errorf("HTTP 代理地址不能为空")
}
normalized, err := forwardplugin.ValidateAndNormalizePlugins(toPluginEntries(df.Plugins))
if err != nil {
return err
}
df.Plugins = fromPluginEntries(normalized)
}
if df.SourceScheme == "" {
df.SourceScheme = "all"
}
if df.SourceLocation == "" {
df.SourceLocation = "/"
}
return nil
}
func toPluginEntries(plugins ForwardPlugins) []forwardplugin.PluginEntry {
if len(plugins) == 0 {
return nil
}
out := make([]forwardplugin.PluginEntry, len(plugins))
for i, p := range plugins {
out[i] = forwardplugin.PluginEntry{
Type: p.Type,
Enabled: p.Enabled,
Config: p.Config,
}
}
return out
}
func fromPluginEntries(entries []forwardplugin.PluginEntry) ForwardPlugins {
if len(entries) == 0 {
return nil
}
out := make(ForwardPlugins, len(entries))
for i, e := range entries {
out[i] = ForwardPluginEntry{
Type: e.Type,
Enabled: e.Enabled,
Config: e.Config,
}
}
return out
}
+145
View File
@@ -0,0 +1,145 @@
package store
import (
"errors"
"time"
)
const ExportVersion = 1
type TunnelExportItem struct {
ClientID uint `json:"client_id"`
ClientName string `json:"client_name,omitempty"`
Name string `json:"name"`
Mode string `json:"mode"`
ListenIP string `json:"listen_ip"`
ListenPort int `json:"listen_port"`
TargetAddr string `json:"target_addr"`
IPForwardMode string `json:"ip_forward_mode"`
CustomHeaders string `json:"custom_headers"`
Status bool `json:"status"`
}
type HostExportItem struct {
ClientID uint `json:"client_id"`
ClientName string `json:"client_name,omitempty"`
Host string `json:"host"`
Location string `json:"location"`
Scheme string `json:"scheme"`
TargetAddr string `json:"target_addr"`
AutoHTTPS bool `json:"auto_https"`
IPForwardMode string `json:"ip_forward_mode"`
CustomHeaders string `json:"custom_headers"`
Status bool `json:"status"`
}
type TunnelExportDoc struct {
Version int `json:"version"`
ExportedAt time.Time `json:"exported_at"`
Items []TunnelExportItem `json:"items"`
}
type HostExportDoc struct {
Version int `json:"version"`
ExportedAt time.Time `json:"exported_at"`
Items []HostExportItem `json:"items"`
}
func TunnelToExportItem(t Tunnel, clientName string) TunnelExportItem {
return TunnelExportItem{
ClientID: t.ClientID,
ClientName: clientName,
Name: t.Name,
Mode: t.Mode,
ListenIP: t.ListenIP,
ListenPort: t.ListenPort,
TargetAddr: t.TargetAddr,
IPForwardMode: t.IPForwardMode,
CustomHeaders: t.CustomHeaders,
Status: t.Status,
}
}
func HostToExportItem(h Host, clientName string) HostExportItem {
return HostExportItem{
ClientID: h.ClientID,
ClientName: clientName,
Host: h.Host,
Location: h.Location,
Scheme: h.Scheme,
TargetAddr: h.TargetAddr,
AutoHTTPS: h.AutoHTTPS,
IPForwardMode: h.IPForwardMode,
CustomHeaders: h.CustomHeaders,
Status: h.Status,
}
}
func (s *Store) FindTunnelByPort(port int) (*Tunnel, error) {
var t Tunnel
if err := s.db.Where("listen_port = ?", port).First(&t).Error; err != nil {
return nil, err
}
return &t, nil
}
func (s *Store) FindHostByRouteGlobal(host, location string) (*Host, error) {
if location == "" {
location = "/"
}
var h Host
if err := s.db.Where("host = ? AND location = ?", host, location).First(&h).Error; err != nil {
return nil, err
}
return &h, nil
}
func (s *Store) GetClientByName(name string) (*Client, error) {
var c Client
if err := s.db.Where("name = ?", name).First(&c).Error; err != nil {
return nil, err
}
return &c, nil
}
func (s *Store) FindTunnelByListen(clientID uint, listenIP string, listenPort int) (*Tunnel, error) {
if listenIP == "" {
listenIP = "0.0.0.0"
}
var t Tunnel
if err := s.db.Where("client_id = ? AND listen_ip = ? AND listen_port = ?", clientID, listenIP, listenPort).First(&t).Error; err != nil {
return nil, err
}
return &t, nil
}
func (s *Store) FindHostByRoute(clientID uint, host, location string) (*Host, error) {
if location == "" {
location = "/"
}
var h Host
if err := s.db.Where("client_id = ? AND host = ? AND location = ?", clientID, host, location).First(&h).Error; err != nil {
return nil, err
}
return &h, nil
}
func ResolveImportClientID(s *Store, clientID uint, clientName string, ownerID uint, isAdmin bool) (uint, error) {
if clientID > 0 {
if err := ClientOwnerCheck(s, clientID, ownerID, isAdmin); err != nil {
return 0, err
}
return clientID, nil
}
if clientName == "" {
return 0, errors.New("client_id or client_name required")
}
c, err := s.GetClientByName(clientName)
if err != nil {
return 0, errors.New("client not found: " + clientName)
}
if err := ClientOwnerCheck(s, c.ID, ownerID, isAdmin); err != nil {
return 0, err
}
return c.ID, nil
}
+236
View File
@@ -0,0 +1,236 @@
package store
import (
"database/sql/driver"
"encoding/json"
"fmt"
"strings"
"time"
)
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"uniqueIndex;size:64;not null" json:"username"`
PasswordHash string `gorm:"size:255;not null" json:"-"`
Role string `gorm:"size:16;not null;default:visitor" json:"role"`
Status bool `gorm:"not null;default:true" json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Client struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:128;not null" json:"name"`
VerifyKey string `gorm:"uniqueIndex;size:64;not null" json:"verify_key"`
OwnerUserID uint `gorm:"index;not null" json:"owner_user_id"`
RateLimit int64 `gorm:"default:0" json:"rate_limit"`
MaxConn int `gorm:"default:0" json:"max_conn"`
ExpireAt *time.Time `json:"expire_at"`
Status bool `gorm:"not null;default:true" json:"status"`
IPForwardMode string `gorm:"size:16;default:replace" json:"ip_forward_mode"` // replace, append
CustomHeaders string `gorm:"type:text" json:"custom_headers"` // JSON map
InletFlow int64 `gorm:"default:0" json:"inlet_flow"`
ExportFlow int64 `gorm:"default:0" json:"export_flow"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Tunnel struct {
ID uint `gorm:"primaryKey" json:"id"`
ClientID uint `gorm:"index;not null" json:"client_id"`
Name string `gorm:"size:128" json:"name"`
Mode string `gorm:"size:16;not null" json:"mode"` // tcp, udp
ListenIP string `gorm:"size:64;default:0.0.0.0" json:"listen_ip"`
ListenPort int `gorm:"not null;uniqueIndex" json:"listen_port"`
TargetAddr string `gorm:"size:256;not null" json:"target_addr"`
IPForwardMode string `gorm:"size:16;default:replace" json:"ip_forward_mode"` // replace, append
CustomHeaders string `gorm:"type:text" json:"custom_headers"` // JSON map
Status bool `gorm:"not null;default:true" json:"status"`
RunStatus bool `gorm:"not null;default:false" json:"run_status"`
InletFlow int64 `gorm:"default:0" json:"inlet_flow"`
ExportFlow int64 `gorm:"default:0" json:"export_flow"`
VisitorAuthEnabled bool `gorm:"not null;default:false" json:"visitor_auth_enabled"`
VisitorBindMode string `gorm:"size:16;default:all" json:"visitor_bind_mode"` // all, selected
VisitorTTLSeconds int `gorm:"default:7200" json:"visitor_ttl_seconds"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Host struct {
ID uint `gorm:"primaryKey" json:"id"`
ClientID uint `gorm:"index;not null" json:"client_id"`
Host string `gorm:"size:256;not null;uniqueIndex:idx_host_route" json:"host"`
Location string `gorm:"size:256;default:/;uniqueIndex:idx_host_route" json:"location"`
Scheme string `gorm:"size:16;default:all" json:"scheme"` // http, https, all
TargetAddr string `gorm:"size:256;not null" json:"target_addr"`
CertPath string `gorm:"size:512" json:"cert_path"`
KeyPath string `gorm:"size:512" json:"key_path"`
AutoHTTPS bool `gorm:"default:false" json:"auto_https"`
IPForwardMode string `gorm:"size:16;default:replace" json:"ip_forward_mode"` // replace, append
CustomHeaders string `gorm:"type:text" json:"custom_headers"` // JSON map
Status bool `gorm:"not null;default:true" json:"status"`
InletFlow int64 `gorm:"default:0" json:"inlet_flow"`
ExportFlow int64 `gorm:"default:0" json:"export_flow"`
VisitorAuthEnabled bool `gorm:"not null;default:false" json:"visitor_auth_enabled"`
VisitorBindMode string `gorm:"size:16;default:all" json:"visitor_bind_mode"` // all, selected
VisitorTTLSeconds int `gorm:"default:7200" json:"visitor_ttl_seconds"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ResourceVisitor struct {
ID uint `gorm:"primaryKey" json:"id"`
ResourceType string `gorm:"size:16;not null;uniqueIndex:idx_rv_bind" json:"resource_type"`
ResourceID uint `gorm:"not null;uniqueIndex:idx_rv_bind" json:"resource_id"`
UserID uint `gorm:"not null;uniqueIndex:idx_rv_bind" json:"user_id"`
CreatedAt time.Time `json:"created_at"`
}
type IPRule struct {
ID uint `gorm:"primaryKey" json:"id"`
Scope string `gorm:"size:16;not null;index" json:"scope"` // global, client, host, tunnel
ResourceID uint `gorm:"index;default:0" json:"resource_id"`
Type string `gorm:"size:8;not null" json:"type"` // allow, deny
Pattern string `gorm:"size:512;not null" json:"pattern"`
PatternKind string `gorm:"size:16;not null;default:exact" json:"pattern_kind"` // exact, cidr, regex
Priority int `gorm:"default:0" json:"priority"`
Remark string `gorm:"size:256" json:"remark"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type AuthPolicy struct {
ID uint `gorm:"primaryKey" json:"id"`
ResourceType string `gorm:"size:16;not null;index" json:"resource_type"` // host, tunnel
ResourceID uint `gorm:"index;not null" json:"resource_id"`
Type string `gorm:"size:16;not null" json:"type"` // basic, form, callback
Password string `gorm:"size:256" json:"-"`
CallbackURL string `gorm:"size:512" json:"callback_url"`
Enabled bool `gorm:"not null;default:true" json:"enabled"`
TTLSeconds int `gorm:"default:7200" json:"ttl_seconds"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type FlowStat struct {
ID uint `gorm:"primaryKey" json:"id"`
ResourceType string `gorm:"size:16;not null;uniqueIndex:idx_flow_resource" json:"resource_type"`
ResourceID uint `gorm:"not null;uniqueIndex:idx_flow_resource" json:"resource_id"`
InletBytes int64 `gorm:"default:0" json:"inlet_bytes"`
ExportBytes int64 `gorm:"default:0" json:"export_bytes"`
UpdatedAt time.Time `json:"updated_at"`
}
type RequestIP struct {
ID uint `gorm:"primaryKey" json:"id"`
ResourceType string `gorm:"size:16;not null;uniqueIndex:idx_req_ip" json:"resource_type"`
ResourceID uint `gorm:"not null;uniqueIndex:idx_req_ip" json:"resource_id"`
IP string `gorm:"size:64;not null;uniqueIndex:idx_req_ip" json:"ip"`
DirectIP string `gorm:"size:64" json:"direct_ip"`
HitCount int64 `gorm:"default:0" json:"hit_count"`
LastSeenAt time.Time `json:"last_seen_at"`
}
type Session struct {
ID uint `gorm:"primaryKey" json:"id"`
Token string `gorm:"uniqueIndex;size:512;not null" json:"token"`
UserID uint `gorm:"index;not null" json:"user_id"`
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
}
type APIKey struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:128;not null" json:"name"`
KeyPrefix string `gorm:"size:16;not null" json:"key_prefix"`
KeyHash string `gorm:"size:64;not null;uniqueIndex" json:"-"`
UserID uint `gorm:"index;not null" json:"user_id"`
Status bool `gorm:"not null;default:true" json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// VisitorAccessLog records visitor login and resource access (max 200 per user retained).
type VisitorAccessLog struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index:idx_va_user_time;not null" json:"user_id"`
ResourceType string `gorm:"size:16;not null" json:"resource_type"`
ResourceID uint `gorm:"not null" json:"resource_id"`
Action string `gorm:"size:16;not null" json:"action"` // login, access
Method string `gorm:"size:16" json:"method"`
Path string `gorm:"size:512" json:"path"`
IP string `gorm:"size:64" json:"ip"`
UserAgent string `gorm:"size:256" json:"user_agent"`
CreatedAt time.Time `gorm:"index:idx_va_user_time" json:"created_at"`
}
// DomainForward maps a public source domain to another URL (redirect or reverse proxy).
// Unlike Host (penetration), this is handled entirely on the server without Agent.
type DomainForward struct {
ID uint `gorm:"primaryKey" json:"id"`
SourceHost string `gorm:"size:256;not null;uniqueIndex:idx_df_route" json:"source_host"`
SourceLocation string `gorm:"size:256;default:/;uniqueIndex:idx_df_route" json:"source_location"`
SourceScheme string `gorm:"size:16;default:all" json:"source_scheme"` // http, https, all
Mode string `gorm:"size:16;not null" json:"mode"` // redirect, proxy
TargetURL string `gorm:"size:1024;not null" json:"target_url"`
RedirectMode string `gorm:"size:16;default:preserve" json:"redirect_mode"` // preserve, fixed
RedirectCode int `gorm:"default:302" json:"redirect_code"`
ProxyType string `gorm:"size:16;default:http" json:"proxy_type"` // http(direct), socks5, http_proxy
Socks5Addr string `gorm:"size:256" json:"socks5_addr"`
Socks5User string `gorm:"size:128" json:"socks5_user"`
Socks5Pass string `gorm:"size:128" json:"-"`
HTTPProxyAddr string `gorm:"size:512" json:"http_proxy_addr"`
HTTPProxyUser string `gorm:"size:128" json:"http_proxy_user"`
HTTPProxyPass string `gorm:"size:128" json:"-"`
PreserveHost bool `gorm:"default:false" json:"preserve_host"`
Plugins ForwardPlugins `gorm:"type:text" json:"plugins"`
Status bool `gorm:"not null;default:true" json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ForwardPluginEntry struct {
Type string `json:"type"`
Enabled bool `json:"enabled"`
Config json.RawMessage `json:"config"`
}
type ForwardPlugins []ForwardPluginEntry
func (p ForwardPlugins) Value() (driver.Value, error) {
if len(p) == 0 {
return "[]", nil
}
b, err := json.Marshal([]ForwardPluginEntry(p))
if err != nil {
return nil, err
}
return string(b), nil
}
func (p *ForwardPlugins) Scan(value interface{}) error {
if value == nil {
*p = nil
return nil
}
var raw string
switch v := value.(type) {
case string:
raw = v
case []byte:
raw = string(v)
default:
return fmt.Errorf("unsupported plugins value type %T", value)
}
raw = strings.TrimSpace(raw)
if raw == "" || raw == "null" {
*p = nil
return nil
}
var items []ForwardPluginEntry
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return err
}
*p = items
return nil
}
+213
View File
@@ -0,0 +1,213 @@
package store
import (
"fmt"
"strconv"
"gorm.io/gorm"
)
type PageParams struct {
Page int
PageSize int
Q string
}
func NormalizePage(p PageParams) (offset, limit int) {
if p.Page < 1 {
p.Page = 1
}
if p.PageSize < 1 {
p.PageSize = 20
}
if p.PageSize > 200 {
p.PageSize = 200
}
return (p.Page - 1) * p.PageSize, p.PageSize
}
func tunnelOwnerScope(db *gorm.DB, clientID, ownerID uint, isAdmin bool) *gorm.DB {
q := db.Model(&Tunnel{})
if clientID > 0 {
return q.Where("client_id = ?", clientID)
}
if !isAdmin {
var ids []uint
db.Model(&Client{}).Where("owner_user_id = ?", ownerID).Pluck("id", &ids)
if len(ids) == 0 {
return q.Where("1 = 0")
}
return q.Where("client_id IN ?", ids)
}
return q
}
func hostOwnerScope(db *gorm.DB, clientID, ownerID uint, isAdmin bool) *gorm.DB {
q := db.Model(&Host{})
if clientID > 0 {
return q.Where("client_id = ?", clientID)
}
if !isAdmin {
var ids []uint
db.Model(&Client{}).Where("owner_user_id = ?", ownerID).Pluck("id", &ids)
if len(ids) == 0 {
return q.Where("1 = 0")
}
return q.Where("client_id IN ?", ids)
}
return q
}
func applyTunnelSearch(q *gorm.DB, keyword string) *gorm.DB {
if keyword == "" {
return q
}
like := "%" + keyword + "%"
portLike := "%" + keyword + "%"
return q.Where(
"name LIKE ? OR target_addr LIKE ? OR CAST(listen_port AS TEXT) LIKE ?",
like, like, portLike,
)
}
func applyHostSearch(q *gorm.DB, keyword string) *gorm.DB {
if keyword == "" {
return q
}
like := "%" + keyword + "%"
return q.Where("host LIKE ? OR target_addr LIKE ? OR location LIKE ?", like, like, like)
}
func applyDomainForwardSearch(q *gorm.DB, keyword string) *gorm.DB {
if keyword == "" {
return q
}
like := "%" + keyword + "%"
return q.Where(
"source_host LIKE ? OR target_url LIKE ? OR source_location LIKE ?",
like, like, like,
)
}
func (s *Store) ListDomainForwardsPaged(p PageParams) ([]DomainForward, int64, error) {
var items []DomainForward
var total int64
q := applyDomainForwardSearch(s.db.Model(&DomainForward{}), p.Q)
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
offset, limit := NormalizePage(p)
err := q.Order("id asc").Offset(offset).Limit(limit).Find(&items).Error
return items, total, err
}
func (s *Store) ListTunnelsPaged(clientID, ownerID uint, isAdmin bool, p PageParams) ([]Tunnel, int64, error) {
var tunnels []Tunnel
var total int64
q := tunnelOwnerScope(s.db, clientID, ownerID, isAdmin)
q = applyTunnelSearch(q, p.Q)
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
offset, limit := NormalizePage(p)
err := q.Order("id asc").Offset(offset).Limit(limit).Find(&tunnels).Error
return tunnels, total, err
}
func (s *Store) ListHostsPaged(clientID, ownerID uint, isAdmin bool, p PageParams) ([]Host, int64, error) {
var hosts []Host
var total int64
q := hostOwnerScope(s.db, clientID, ownerID, isAdmin)
q = applyHostSearch(q, p.Q)
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
offset, limit := NormalizePage(p)
err := q.Order("id asc").Offset(offset).Limit(limit).Find(&hosts).Error
return hosts, total, err
}
func (s *Store) TunnelPortInUse(port int, excludeID uint) (bool, error) {
if port <= 0 {
return false, fmt.Errorf("invalid port")
}
q := s.db.Model(&Tunnel{}).Where("listen_port = ?", port)
if excludeID > 0 {
q = q.Where("id <> ?", excludeID)
}
var count int64
if err := q.Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func (s *Store) HostRouteInUse(host, location string, excludeID uint) (bool, error) {
if host == "" {
return false, fmt.Errorf("host required")
}
if location == "" {
location = "/"
}
q := s.db.Model(&Host{}).Where("host = ? AND location = ?", host, location)
if excludeID > 0 {
q = q.Where("id <> ?", excludeID)
}
var count int64
if err := q.Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func (s *Store) ListTunnelsByFlow(limit, offset int) ([]Tunnel, int64, error) {
var tunnels []Tunnel
var total int64
q := s.db.Model(&Tunnel{})
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
if limit <= 0 {
limit = 10
}
err := q.Order("(inlet_flow + export_flow) desc").Offset(offset).Limit(limit).Find(&tunnels).Error
return tunnels, total, err
}
func (s *Store) ListHostsByFlow(limit, offset int) ([]Host, int64, error) {
var hosts []Host
var total int64
q := s.db.Model(&Host{})
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
if limit <= 0 {
limit = 10
}
err := q.Order("(inlet_flow + export_flow) desc").Offset(offset).Limit(limit).Find(&hosts).Error
return hosts, total, err
}
func (s *Store) ListAllRequestIPs(limit, offset int, qstr string) ([]RequestIP, int64, error) {
var items []RequestIP
var total int64
q := s.db.Model(&RequestIP{})
if qstr != "" {
like := "%" + qstr + "%"
q = q.Where("ip LIKE ? OR direct_ip LIKE ?", like, like)
}
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
if limit <= 0 {
limit = 50
}
err := q.Order("hit_count desc").Offset(offset).Limit(limit).Find(&items).Error
return items, total, err
}
func ParsePageQuery(pageStr, pageSizeStr, q string) PageParams {
page, _ := strconv.Atoi(pageStr)
pageSize, _ := strconv.Atoi(pageSizeStr)
return PageParams{Page: page, PageSize: pageSize, Q: q}
}
+71
View File
@@ -0,0 +1,71 @@
package store
import (
"encoding/json"
"errors"
"time"
"github.com/wormhole/wormhole/internal/config"
"gorm.io/gorm"
)
const systemSettingsID = 1
type SystemSettings struct {
ID uint `gorm:"primaryKey" json:"id"`
Payload string `gorm:"type:text;not null" json:"-"`
UpdatedAt time.Time `json:"updated_at"`
}
func (s *Store) ensureSystemSettings() error {
var row SystemSettings
err := s.db.First(&row, systemSettingsID).Error
if err == nil {
return nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
cfg := config.Default()
payload, err := json.Marshal(cfg)
if err != nil {
return err
}
return s.db.Create(&SystemSettings{
ID: systemSettingsID,
Payload: string(payload),
}).Error
}
func (s *Store) LoadConfig() (*config.Config, error) {
if err := s.ensureSystemSettings(); err != nil {
return nil, err
}
var row SystemSettings
if err := s.db.First(&row, systemSettingsID).Error; err != nil {
return nil, err
}
cfg := config.Default()
if err := json.Unmarshal([]byte(row.Payload), cfg); err != nil {
return nil, err
}
cfg.Database.Path = config.DefaultDBPath
cfg.Normalize()
return cfg, nil
}
func (s *Store) SaveConfig(cfg *config.Config) error {
if err := s.ensureSystemSettings(); err != nil {
return err
}
cfg.Database.Path = config.DefaultDBPath
cfg.Normalize()
payload, err := json.Marshal(cfg)
if err != nil {
return err
}
return s.db.Model(&SystemSettings{}).Where("id = ?", systemSettingsID).Updates(map[string]interface{}{
"payload": string(payload),
"updated_at": time.Now(),
}).Error
}
+516
View File
@@ -0,0 +1,516 @@
package store
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type Store struct {
db *gorm.DB
}
func Open(path string) (*Store, error) {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, err
}
db, err := gorm.Open(sqlite.Open(path), &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn),
})
if err != nil {
return nil, err
}
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
sqlDB.SetMaxOpenConns(1)
if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL"); err != nil {
return nil, fmt.Errorf("enable WAL: %w", err)
}
if _, err := sqlDB.Exec("PRAGMA busy_timeout=5000"); err != nil {
return nil, fmt.Errorf("set busy_timeout: %w", err)
}
s := &Store{db: db}
if err := s.migrate(); err != nil {
return nil, err
}
if err := s.seed(); err != nil {
return nil, err
}
return s, nil
}
func (s *Store) DB() *gorm.DB {
return s.db
}
func (s *Store) migrate() error {
return s.db.AutoMigrate(
&User{},
&Client{},
&Tunnel{},
&Host{},
&IPRule{},
&AuthPolicy{},
&FlowStat{},
&RequestIP{},
&Session{},
&SystemSettings{},
&APIKey{},
&ResourceVisitor{},
&VisitorAccessLog{},
&DomainForward{},
)
}
func (s *Store) seed() error {
var count int64
s.db.Model(&User{}).Count(&count)
if count > 0 {
return nil
}
hash, err := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost)
if err != nil {
return err
}
admin := User{
Username: "admin",
PasswordHash: string(hash),
Role: "admin",
Status: true,
}
return s.db.Create(&admin).Error
}
func GenerateVerifyKey() string {
return uuid.New().String()
}
func HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}
func CheckPassword(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
func (s *Store) ListClients(ownerID uint, isAdmin bool) ([]Client, error) {
var clients []Client
q := s.db.Order("id asc")
if !isAdmin {
q = q.Where("owner_user_id = ?", ownerID)
}
return clients, q.Find(&clients).Error
}
func (s *Store) GetClientByID(id uint) (*Client, error) {
var c Client
if err := s.db.First(&c, id).Error; err != nil {
return nil, err
}
return &c, nil
}
func (s *Store) GetClientByVerifyKey(key string) (*Client, error) {
var c Client
if err := s.db.Where("verify_key = ? AND status = ?", key, true).First(&c).Error; err != nil {
return nil, err
}
return &c, nil
}
func (s *Store) CreateClient(c *Client) error {
if c.VerifyKey == "" {
c.VerifyKey = GenerateVerifyKey()
}
return s.db.Create(c).Error
}
func (s *Store) UpdateClient(c *Client) error {
return s.db.Save(c).Error
}
func (s *Store) DeleteClient(id uint) error {
return s.db.Transaction(func(tx *gorm.DB) error {
var tunnelIDs, hostIDs []uint
tx.Model(&Tunnel{}).Where("client_id = ?", id).Pluck("id", &tunnelIDs)
tx.Model(&Host{}).Where("client_id = ?", id).Pluck("id", &hostIDs)
for _, tid := range tunnelIDs {
if err := tx.Where("resource_type = ? AND resource_id = ?", "tunnel", tid).Delete(&AuthPolicy{}).Error; err != nil {
return err
}
if err := tx.Where("resource_type = ? AND resource_id = ?", "tunnel", tid).Delete(&ResourceVisitor{}).Error; err != nil {
return err
}
if err := tx.Where("scope = ? AND resource_id = ?", "tunnel", tid).Delete(&IPRule{}).Error; err != nil {
return err
}
if err := tx.Where("resource_type = ? AND resource_id = ?", "tunnel", tid).Delete(&FlowStat{}).Error; err != nil {
return err
}
if err := tx.Where("resource_type = ? AND resource_id = ?", "tunnel", tid).Delete(&RequestIP{}).Error; err != nil {
return err
}
}
for _, hid := range hostIDs {
if err := tx.Where("resource_type = ? AND resource_id = ?", "host", hid).Delete(&AuthPolicy{}).Error; err != nil {
return err
}
if err := tx.Where("resource_type = ? AND resource_id = ?", "host", hid).Delete(&ResourceVisitor{}).Error; err != nil {
return err
}
if err := tx.Where("scope = ? AND resource_id = ?", "host", hid).Delete(&IPRule{}).Error; err != nil {
return err
}
if err := tx.Where("resource_type = ? AND resource_id = ?", "host", hid).Delete(&FlowStat{}).Error; err != nil {
return err
}
if err := tx.Where("resource_type = ? AND resource_id = ?", "host", hid).Delete(&RequestIP{}).Error; err != nil {
return err
}
}
if err := tx.Where("scope = ? AND resource_id = ?", "client", id).Delete(&IPRule{}).Error; err != nil {
return err
}
if err := tx.Where("client_id = ?", id).Delete(&Tunnel{}).Error; err != nil {
return err
}
if err := tx.Where("client_id = ?", id).Delete(&Host{}).Error; err != nil {
return err
}
return tx.Delete(&Client{}, id).Error
})
}
func (s *Store) ListTunnels(clientID uint, ownerID uint, isAdmin bool) ([]Tunnel, error) {
var tunnels []Tunnel
q := s.db.Order("id asc")
if clientID > 0 {
q = q.Where("client_id = ?", clientID)
} else if !isAdmin {
var ids []uint
s.db.Model(&Client{}).Where("owner_user_id = ?", ownerID).Pluck("id", &ids)
if len(ids) == 0 {
return tunnels, nil
}
q = q.Where("client_id IN ?", ids)
}
return tunnels, q.Find(&tunnels).Error
}
func (s *Store) GetTunnelByID(id uint) (*Tunnel, error) {
var t Tunnel
if err := s.db.First(&t, id).Error; err != nil {
return nil, err
}
return &t, nil
}
func (s *Store) CreateTunnel(t *Tunnel) error {
return s.db.Create(t).Error
}
func (s *Store) UpdateTunnel(t *Tunnel) error {
return s.db.Save(t).Error
}
func (s *Store) DeleteTunnel(id uint) error {
_ = s.DeleteResourceVisitors("tunnel", id)
return s.db.Delete(&Tunnel{}, id).Error
}
func (s *Store) ListHosts(clientID uint, ownerID uint, isAdmin bool) ([]Host, error) {
var hosts []Host
q := s.db.Order("id asc")
if clientID > 0 {
q = q.Where("client_id = ?", clientID)
} else if !isAdmin {
var ids []uint
s.db.Model(&Client{}).Where("owner_user_id = ?", ownerID).Pluck("id", &ids)
if len(ids) == 0 {
return hosts, nil
}
q = q.Where("client_id IN ?", ids)
}
return hosts, q.Find(&hosts).Error
}
func (s *Store) GetHostByID(id uint) (*Host, error) {
var h Host
if err := s.db.First(&h, id).Error; err != nil {
return nil, err
}
return &h, nil
}
func (s *Store) CreateHost(h *Host) error {
return s.db.Create(h).Error
}
func (s *Store) UpdateHost(h *Host) error {
return s.db.Save(h).Error
}
func (s *Store) DeleteHost(id uint) error {
_ = s.DeleteResourceVisitors("host", id)
return s.db.Delete(&Host{}, id).Error
}
func (s *Store) ListIPRules(scope string, resourceID uint) ([]IPRule, error) {
var rules []IPRule
q := s.db.Order("priority desc, id asc")
if scope != "" {
q = q.Where("scope = ?", scope)
}
if resourceID > 0 {
q = q.Where("resource_id = ?", resourceID)
}
return rules, q.Find(&rules).Error
}
func (s *Store) CreateIPRule(r *IPRule) error {
return s.db.Create(r).Error
}
func (s *Store) DeleteIPRule(id uint) error {
return s.db.Delete(&IPRule{}, id).Error
}
func (s *Store) ListAuthPolicies(resourceType string, resourceID uint) ([]AuthPolicy, error) {
var policies []AuthPolicy
q := s.db.Order("id desc")
if resourceType != "" {
q = q.Where("resource_type = ?", resourceType)
}
if resourceID > 0 {
q = q.Where("resource_id = ?", resourceID)
}
return policies, q.Find(&policies).Error
}
func (s *Store) GetAuthPolicy(id uint) (*AuthPolicy, error) {
var p AuthPolicy
if err := s.db.First(&p, id).Error; err != nil {
return nil, err
}
return &p, nil
}
func (s *Store) CreateAuthPolicy(p *AuthPolicy) error {
return s.db.Create(p).Error
}
func (s *Store) UpdateAuthPolicy(p *AuthPolicy) error {
return s.db.Save(p).Error
}
func (s *Store) DeleteAuthPolicy(id uint) error {
return s.db.Delete(&AuthPolicy{}, id).Error
}
func (s *Store) ListUsers() ([]User, error) {
var users []User
return users, s.db.Order("id asc").Find(&users).Error
}
func (s *Store) GetUserByUsername(username string) (*User, error) {
var u User
if err := s.db.Where("username = ?", username).First(&u).Error; err != nil {
return nil, err
}
return &u, nil
}
func (s *Store) GetUserByID(id uint) (*User, error) {
var u User
if err := s.db.First(&u, id).Error; err != nil {
return nil, err
}
return &u, nil
}
func (s *Store) CreateUser(u *User) error {
return s.db.Create(u).Error
}
func (s *Store) UpdateUser(u *User) error {
return s.db.Save(u).Error
}
func (s *Store) DeleteUser(id uint, currentUserID uint) error {
if id == currentUserID {
return fmt.Errorf("cannot delete yourself")
}
user, err := s.GetUserByID(id)
if err != nil {
return err
}
if user.Role == "admin" {
var adminCount int64
s.db.Model(&User{}).Where("role = ?", "admin").Count(&adminCount)
if adminCount <= 1 {
return fmt.Errorf("cannot delete the last admin")
}
}
_ = s.DeleteVisitorAccessLogs(id)
return s.db.Delete(&User{}, id).Error
}
func (s *Store) RecordRequestIP(resourceType string, resourceID uint, visitorIP, directIP string) error {
var rec RequestIP
err := s.db.Where("resource_type = ? AND resource_id = ? AND ip = ?",
resourceType, resourceID, visitorIP).First(&rec).Error
now := time.Now()
if err == gorm.ErrRecordNotFound {
rec = RequestIP{
ResourceType: resourceType,
ResourceID: resourceID,
IP: visitorIP,
DirectIP: directIP,
HitCount: 1,
LastSeenAt: now,
}
return s.db.Create(&rec).Error
}
if err != nil {
return err
}
return s.db.Model(&rec).Updates(map[string]interface{}{
"hit_count": rec.HitCount + 1,
"last_seen_at": now,
"direct_ip": directIP,
}).Error
}
func (s *Store) ListRequestIPs(resourceType string, resourceID uint, limit, offset int) ([]RequestIP, int64, error) {
var items []RequestIP
var total int64
q := s.db.Model(&RequestIP{})
if resourceType != "" {
q = q.Where("resource_type = ?", resourceType)
}
if resourceID > 0 {
q = q.Where("resource_id = ?", resourceID)
}
q.Count(&total)
err := q.Order("last_seen_at desc").Limit(limit).Offset(offset).Find(&items).Error
return items, total, err
}
func (s *Store) UpsertFlowStat(resourceType string, resourceID uint, inlet, export int64) error {
var stat FlowStat
err := s.db.Where("resource_type = ? AND resource_id = ?", resourceType, resourceID).First(&stat).Error
if err == gorm.ErrRecordNotFound {
stat = FlowStat{
ResourceType: resourceType,
ResourceID: resourceID,
InletBytes: inlet,
ExportBytes: export,
}
return s.db.Create(&stat).Error
}
if err != nil {
return err
}
return s.db.Model(&stat).Updates(map[string]interface{}{
"inlet_bytes": gorm.Expr("inlet_bytes + ?", inlet),
"export_bytes": gorm.Expr("export_bytes + ?", export),
}).Error
}
func (s *Store) AddClientFlow(id uint, inlet, export int64) error {
return s.db.Model(&Client{}).Where("id = ?", id).Updates(map[string]interface{}{
"inlet_flow": gorm.Expr("inlet_flow + ?", inlet),
"export_flow": gorm.Expr("export_flow + ?", export),
}).Error
}
func (s *Store) AddTunnelFlow(id uint, inlet, export int64) error {
return s.db.Model(&Tunnel{}).Where("id = ?", id).Updates(map[string]interface{}{
"inlet_flow": gorm.Expr("inlet_flow + ?", inlet),
"export_flow": gorm.Expr("export_flow + ?", export),
}).Error
}
func (s *Store) AddHostFlow(id uint, inlet, export int64) error {
return s.db.Model(&Host{}).Where("id = ?", id).Updates(map[string]interface{}{
"inlet_flow": gorm.Expr("inlet_flow + ?", inlet),
"export_flow": gorm.Expr("export_flow + ?", export),
}).Error
}
func (s *Store) DashboardStats() (map[string]interface{}, error) {
var clientTotal, clientOnline int64
var tunnelTotal, tunnelRunning int64
var hostTotal int64
var inletFlow, exportFlow int64
s.db.Model(&Client{}).Count(&clientTotal)
s.db.Model(&Tunnel{}).Count(&tunnelTotal)
s.db.Model(&Tunnel{}).Where("run_status = ?", true).Count(&tunnelRunning)
s.db.Model(&Host{}).Count(&hostTotal)
s.db.Model(&Client{}).Select("COALESCE(SUM(inlet_flow),0)").Scan(&inletFlow)
s.db.Model(&Client{}).Select("COALESCE(SUM(export_flow),0)").Scan(&exportFlow)
return map[string]interface{}{
"client_total": clientTotal,
"client_online": clientOnline,
"tunnel_total": tunnelTotal,
"tunnel_running": tunnelRunning,
"host_total": hostTotal,
"inlet_flow": inletFlow,
"export_flow": exportFlow,
}, nil
}
func (s *Store) TopClientsByFlow(limit int) ([]Client, error) {
var clients []Client
err := s.db.Order("(inlet_flow + export_flow) desc").Limit(limit).Find(&clients).Error
return clients, err
}
func (s *Store) TopTunnelsByFlow(limit int) ([]Tunnel, error) {
var tunnels []Tunnel
err := s.db.Order("(inlet_flow + export_flow) desc").Limit(limit).Find(&tunnels).Error
return tunnels, err
}
func (s *Store) TopHostsByFlow(limit int) ([]Host, error) {
var hosts []Host
err := s.db.Order("(inlet_flow + export_flow) desc").Limit(limit).Find(&hosts).Error
return hosts, err
}
func (s *Store) TopRequestIPs(limit int) ([]RequestIP, error) {
var items []RequestIP
err := s.db.Order("hit_count desc").Limit(limit).Find(&items).Error
return items, err
}
func ClientOwnerCheck(s *Store, clientID, userID uint, isAdmin bool) error {
if isAdmin {
return nil
}
c, err := s.GetClientByID(clientID)
if err != nil {
return err
}
if c.OwnerUserID != userID {
return fmt.Errorf("permission denied")
}
return nil
}
+88
View File
@@ -0,0 +1,88 @@
package store
import "gorm.io/gorm"
func (s *Store) ListVisitors() ([]User, error) {
var users []User
err := s.db.Where("role = ?", "visitor").Order("id asc").Find(&users).Error
return users, err
}
func (s *Store) ListResourceVisitorIDs(resourceType string, resourceID uint) ([]uint, error) {
var bindings []ResourceVisitor
err := s.db.Where("resource_type = ? AND resource_id = ?", resourceType, resourceID).
Order("user_id asc").Find(&bindings).Error
if err != nil {
return nil, err
}
ids := make([]uint, len(bindings))
for i, b := range bindings {
ids[i] = b.UserID
}
return ids, nil
}
func (s *Store) SetResourceVisitors(resourceType string, resourceID uint, userIDs []uint) error {
return s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("resource_type = ? AND resource_id = ?", resourceType, resourceID).
Delete(&ResourceVisitor{}).Error; err != nil {
return err
}
for _, uid := range userIDs {
rv := ResourceVisitor{
ResourceType: resourceType,
ResourceID: resourceID,
UserID: uid,
}
if err := tx.Create(&rv).Error; err != nil {
return err
}
}
return nil
})
}
func (s *Store) DeleteResourceVisitors(resourceType string, resourceID uint) error {
return s.db.Where("resource_type = ? AND resource_id = ?", resourceType, resourceID).
Delete(&ResourceVisitor{}).Error
}
func (s *Store) ListAllResourceVisitors() ([]ResourceVisitor, error) {
var items []ResourceVisitor
err := s.db.Order("resource_type, resource_id, user_id").Find(&items).Error
return items, err
}
const MaxVisitorAccessLogsPerUser = 200
func (s *Store) AppendVisitorAccessLog(entry *VisitorAccessLog) error {
return s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(entry).Error; err != nil {
return err
}
var keepIDs []uint
if err := tx.Model(&VisitorAccessLog{}).Where("user_id = ?", entry.UserID).
Order("id desc").Limit(MaxVisitorAccessLogsPerUser).Pluck("id", &keepIDs).Error; err != nil {
return err
}
if len(keepIDs) == 0 {
return nil
}
return tx.Where("user_id = ? AND id NOT IN ?", entry.UserID, keepIDs).
Delete(&VisitorAccessLog{}).Error
})
}
func (s *Store) ListVisitorAccessLogs(userID uint, limit int) ([]VisitorAccessLog, error) {
if limit <= 0 || limit > MaxVisitorAccessLogsPerUser {
limit = MaxVisitorAccessLogsPerUser
}
var items []VisitorAccessLog
err := s.db.Where("user_id = ?", userID).
Order("id desc").Limit(limit).Find(&items).Error
return items, err
}
func (s *Store) DeleteVisitorAccessLogs(userID uint) error {
return s.db.Where("user_id = ?", userID).Delete(&VisitorAccessLog{}).Error
}
+3
View File
@@ -0,0 +1,3 @@
package version
const Version = "0.1.0"
+113
View File
@@ -0,0 +1,113 @@
-- Wormhole schema (also applied via GORM AutoMigrate)
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
status INTEGER NOT NULL DEFAULT 1,
created_at DATETIME,
updated_at DATETIME
);
CREATE TABLE IF NOT EXISTS clients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
verify_key TEXT NOT NULL UNIQUE,
owner_user_id INTEGER NOT NULL,
rate_limit INTEGER DEFAULT 0,
max_conn INTEGER DEFAULT 0,
expire_at DATETIME,
status INTEGER NOT NULL DEFAULT 1,
inlet_flow INTEGER DEFAULT 0,
export_flow INTEGER DEFAULT 0,
created_at DATETIME,
updated_at DATETIME
);
CREATE TABLE IF NOT EXISTS tunnels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL,
name TEXT,
mode TEXT NOT NULL,
listen_ip TEXT DEFAULT '0.0.0.0',
listen_port INTEGER NOT NULL,
target_addr TEXT NOT NULL,
status INTEGER NOT NULL DEFAULT 1,
run_status INTEGER NOT NULL DEFAULT 0,
inlet_flow INTEGER DEFAULT 0,
export_flow INTEGER DEFAULT 0,
created_at DATETIME,
updated_at DATETIME
);
CREATE TABLE IF NOT EXISTS hosts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL,
host TEXT NOT NULL,
location TEXT DEFAULT '/',
scheme TEXT DEFAULT 'all',
target_addr TEXT NOT NULL,
cert_path TEXT,
key_path TEXT,
auto_https INTEGER DEFAULT 0,
status INTEGER NOT NULL DEFAULT 1,
inlet_flow INTEGER DEFAULT 0,
export_flow INTEGER DEFAULT 0,
created_at DATETIME,
updated_at DATETIME
);
CREATE TABLE IF NOT EXISTS ip_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope TEXT NOT NULL,
resource_id INTEGER DEFAULT 0,
type TEXT NOT NULL,
pattern TEXT NOT NULL,
pattern_kind TEXT NOT NULL DEFAULT 'exact',
priority INTEGER DEFAULT 0,
remark TEXT,
created_at DATETIME,
updated_at DATETIME
);
CREATE TABLE IF NOT EXISTS auth_policies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
resource_type TEXT NOT NULL,
resource_id INTEGER NOT NULL,
type TEXT NOT NULL,
password TEXT,
callback_url TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
ttl_seconds INTEGER DEFAULT 7200,
created_at DATETIME,
updated_at DATETIME
);
CREATE TABLE IF NOT EXISTS flow_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
resource_type TEXT NOT NULL,
resource_id INTEGER NOT NULL,
inlet_bytes INTEGER DEFAULT 0,
export_bytes INTEGER DEFAULT 0,
updated_at DATETIME,
UNIQUE(resource_type, resource_id)
);
CREATE TABLE IF NOT EXISTS request_ips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
resource_type TEXT NOT NULL,
resource_id INTEGER NOT NULL,
ip TEXT NOT NULL,
hit_count INTEGER DEFAULT 0,
last_seen_at DATETIME,
UNIQUE(resource_type, resource_id, ip)
);
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL,
expires_at DATETIME,
created_at DATETIME
);
+10
View File
@@ -0,0 +1,10 @@
-- Proxy headers and IP forward mode for clients, tunnels, hosts
ALTER TABLE clients ADD COLUMN ip_forward_mode TEXT DEFAULT 'real';
ALTER TABLE clients ADD COLUMN custom_headers TEXT DEFAULT '';
ALTER TABLE tunnels ADD COLUMN ip_forward_mode TEXT DEFAULT 'real';
ALTER TABLE tunnels ADD COLUMN custom_headers TEXT DEFAULT '';
ALTER TABLE hosts ADD COLUMN ip_forward_mode TEXT DEFAULT 'real';
ALTER TABLE hosts ADD COLUMN custom_headers TEXT DEFAULT '';
+7
View File
@@ -0,0 +1,7 @@
-- System settings stored in DB (also via GORM AutoMigrate)
CREATE TABLE IF NOT EXISTS system_settings (
id INTEGER PRIMARY KEY,
payload TEXT NOT NULL,
updated_at DATETIME
);
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# 构建 linux/amd64 镜像并推送到私有仓库(不推 Docker Hub)
set -euo pipefail
REGISTRY="${REGISTRY:-registry.rc707blog.top/rose_cat707}"
PLATFORM="${PLATFORM:-linux/amd64}"
TAG="${TAG:-latest}"
IMAGE="${IMAGE:-wormhole}"
FULL_IMAGE="${REGISTRY}/${IMAGE}:${TAG}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
echo "==> 私有仓库: ${FULL_IMAGE}"
echo "==> 平台: ${PLATFORM}"
if ! docker info >/dev/null 2>&1; then
echo "error: Docker 未运行,请先启动 Docker Desktop" >&2
exit 1
fi
echo "==> building (local cache, no Docker Hub pull)..."
docker build \
--platform "${PLATFORM}" \
--pull=false \
-f Dockerfile \
-t "${FULL_IMAGE}" \
.
echo "==> pushing to private registry..."
docker push "${FULL_IMAGE}"
echo "==> done"
echo " ${FULL_IMAGE}"
echo ""
echo "服务端: docker run ... ${FULL_IMAGE}"
echo "Agent: docker run ... ${FULL_IMAGE} agent -server <ip>:8528 -key <key> [-tls]"
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# 构建并推送多架构镜像 (amd64 + arm64)
set -euo pipefail
REGISTRY="${REGISTRY:-registry.rc707blog.top/rose_cat707}"
PLATFORM="${PLATFORM:-linux/amd64,linux/arm64}"
TAG="${TAG:-latest}"
IMAGE="${IMAGE:-wormhole}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
if ! docker info >/dev/null 2>&1; then
echo "error: Docker 未运行" >&2
exit 1
fi
if ! docker buildx inspect wormhole-builder >/dev/null 2>&1; then
docker buildx create --name wormhole-builder --use
else
docker buildx use wormhole-builder
fi
docker buildx inspect --bootstrap >/dev/null
docker buildx build \
--platform "$PLATFORM" \
-f Dockerfile \
-t "${REGISTRY}/${IMAGE}:${TAG}" \
--push \
.
echo "pushed ${REGISTRY}/${IMAGE}:${TAG} (${PLATFORM})"
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
set -eu
exec /usr/local/bin/wormhole "$@"
+1
View File
@@ -0,0 +1 @@
registry=https://registry.npmmirror.com
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Wormhole</title>
<script type="module" crossorigin src="/assets/index-6-ZkUqlB.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-WASRcumQ.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
package web
import "embed"
//go:embed dist/*
var Dist embed.FS
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Wormhole</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1846
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "wormhole-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.7.7",
"echarts": "^5.5.1",
"element-plus": "^2.8.4",
"vue": "^3.5.11",
"vue-i18n": "^9.14.4",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.4",
"vite": "^5.4.8"
}
}
+15
View File
@@ -0,0 +1,15 @@
<template>
<el-config-provider :locale="epLocale">
<router-view />
</el-config-provider>
</template>
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import enLocale from 'element-plus/dist/locale/en.mjs'
const { locale } = useI18n()
const epLocale = computed(() => (locale.value === 'en' ? enLocale : zhCn))
</script>
+27
View File
@@ -0,0 +1,27 @@
import axios from 'axios'
const api = axios.create({ baseURL: '/api' })
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('token')
localStorage.removeItem('user')
if (!window.location.pathname.includes('/login')) {
window.location.href = '/login'
}
}
return Promise.reject(err)
}
)
export default api
+19
View File
@@ -0,0 +1,19 @@
<template>
<div class="app-card">
<div v-if="title || $slots.header" class="app-card__header">
<slot name="header">
<h3 class="app-card__title">{{ title }}</h3>
</slot>
</div>
<div :class="padded ? 'app-card__body--padded' : ''">
<slot />
</div>
</div>
</template>
<script setup>
defineProps({
title: { type: String, default: '' },
padded: { type: Boolean, default: false },
})
</script>
+13
View File
@@ -0,0 +1,13 @@
<template>
<svg :width="size" :height="size" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L2 7L12 12L22 7L12 2Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
<path d="M2 17L12 22L22 17" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
<path d="M2 12L12 17L22 12" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
</svg>
</template>
<script setup>
defineProps({
size: { type: [Number, String], default: 20 },
})
</script>
+37
View File
@@ -0,0 +1,37 @@
<template>
<div ref="root" class="chart-box" />
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue'
import * as echarts from 'echarts'
const props = defineProps({
option: { type: Object, default: null },
})
const root = ref(null)
let chart
function resize() {
chart?.resize()
}
function render() {
if (!chart || !props.option) return
chart.setOption(props.option, { notMerge: true })
}
onMounted(() => {
chart = echarts.init(root.value)
render()
window.addEventListener('resize', resize)
})
onUnmounted(() => {
window.removeEventListener('resize', resize)
chart?.dispose()
})
watch(() => props.option, render, { deep: true })
</script>
+36
View File
@@ -0,0 +1,36 @@
<template>
<el-select
:model-value="modelValue"
:disabled="disabled"
filterable
:placeholder="t('clientSelect.placeholder')"
class="w-full"
@update:model-value="$emit('update:modelValue', $event)"
>
<el-option
v-for="c in clients"
:key="c.id"
:label="clientLabel(c)"
:value="c.id"
/>
</el-select>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
defineProps({
modelValue: { type: Number, default: null },
clients: { type: Array, default: () => [] },
disabled: { type: Boolean, default: false },
})
defineEmits(['update:modelValue'])
function clientLabel(c) {
const status = c.online ? t('common.online') : t('common.offline')
return t('clientSelect.label', { name: c.name, id: c.id, status })
}
</script>
+166
View File
@@ -0,0 +1,166 @@
<template>
<div class="plugin-editor">
<div v-for="(plugin, index) in modelValue" :key="index" class="plugin-item">
<div class="plugin-head">
<el-tag size="small">{{ labelOf(plugin.type) }}</el-tag>
<span class="plugin-type">{{ plugin.type }}</span>
<div class="plugin-actions">
<el-switch v-model="plugin.enabled" size="small" />
<el-button size="small" link :disabled="index === 0" @click="move(index, -1)">{{ t('forwardPlugin.moveUp') }}</el-button>
<el-button size="small" link :disabled="index === modelValue.length - 1" @click="move(index, 1)">{{ t('forwardPlugin.moveDown') }}</el-button>
<el-button size="small" link type="danger" @click="remove(index)">{{ t('common.delete') }}</el-button>
</div>
</div>
<template v-if="plugin.type === 'sub_filter'">
<div v-for="(rule, ri) in plugin.config.filters" :key="ri" class="filter-row">
<el-input v-model="rule.from" :placeholder="t('forwardPlugin.find')" />
<el-input v-model="rule.to" :placeholder="t('forwardPlugin.replaceWith')" />
<el-button link type="danger" @click="removeFilter(plugin, ri)">{{ t('forwardPlugin.removeShort') }}</el-button>
</div>
<el-button size="small" @click="addFilter(plugin)">{{ t('forwardPlugin.addRule') }}</el-button>
<el-form-item :label="t('forwardPlugin.mimeTypes')" label-width="90px" class="plugin-mime">
<el-select v-model="plugin.config.types" multiple filterable allow-create default-first-option class="w-full">
<el-option label="text/html" value="text/html" />
<el-option label="text/css" value="text/css" />
<el-option label="application/javascript" value="application/javascript" />
</el-select>
</el-form-item>
</template>
<template v-else-if="plugin.type === 'inject_js'">
<el-input v-model="plugin.config.script" type="textarea" :rows="4" :placeholder="t('forwardPlugin.scriptPlaceholder')" />
</template>
<template v-else-if="plugin.type === 'cookie_domain'">
<div class="pair-row">
<el-input v-model="plugin.config.from" :placeholder="t('forwardPlugin.fromDomain')" />
<el-input v-model="plugin.config.to" :placeholder="t('forwardPlugin.toDomain')" />
</div>
</template>
<template v-else-if="plugin.type === 'response_cache'">
<div class="pair-row">
<el-input-number v-model="plugin.config.max_mb" :min="0" :max="1024" />
<span class="hint-inline">{{ t('forwardPlugin.mbHint') }}</span>
</div>
<div class="pair-row">
<el-input-number v-model="plugin.config.ttl_sec" :min="1" :max="86400" />
<span class="hint-inline">{{ t('forwardPlugin.ttlSec') }}</span>
</div>
</template>
</div>
<el-dropdown trigger="click" @command="addPlugin">
<el-button type="primary" plain size="small">{{ t('forwardPlugin.addPlugin') }}</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="t in types" :key="t.type" :command="t.type">{{ t.label }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps({
modelValue: { type: Array, default: () => [] },
types: { type: Array, default: () => [] },
})
const emit = defineEmits(['update:modelValue'])
const typeMap = computed(() => {
const m = {}
for (const t of props.types) m[t.type] = t
return m
})
function labelOf(type) {
return typeMap.value[type]?.label || type
}
function defaultConfig(type) {
const meta = typeMap.value[type]
if (!meta?.default_config) return {}
return JSON.parse(JSON.stringify(meta.default_config))
}
function addPlugin(type) {
const next = [...props.modelValue, { type, enabled: true, config: defaultConfig(type) }]
emit('update:modelValue', next)
}
function remove(index) {
const next = props.modelValue.filter((_, i) => i !== index)
emit('update:modelValue', next)
}
function move(index, delta) {
const next = [...props.modelValue]
const target = index + delta
if (target < 0 || target >= next.length) return
;[next[index], next[target]] = [next[target], next[index]]
emit('update:modelValue', next)
}
function addFilter(plugin) {
if (!plugin.config.filters) plugin.config.filters = []
plugin.config.filters.push({ from: '', to: '' })
}
function removeFilter(plugin, index) {
plugin.config.filters.splice(index, 1)
}
</script>
<style scoped>
.plugin-editor {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.plugin-item {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-3);
}
.plugin-head {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.plugin-type {
color: var(--muted-foreground);
font-size: 0.75rem;
}
.plugin-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: var(--space-1);
}
.plugin-mime {
margin-top: var(--space-2);
}
.filter-row,
.pair-row {
display: flex;
gap: var(--space-2);
align-items: center;
margin-bottom: var(--space-2);
}
.pair-row + .pair-row {
margin-top: var(--space-2);
}
.hint-inline {
font-size: 0.75rem;
color: var(--muted-foreground);
white-space: nowrap;
}
</style>
+109
View File
@@ -0,0 +1,109 @@
<template>
<el-button @click="handleExport" :loading="exporting">{{ t('common.export') }}</el-button>
<el-button @click="openImport">{{ t('common.import') }}</el-button>
<input
ref="fileInput"
type="file"
accept="application/json,.json"
class="hidden-input"
@change="onFileChange"
/>
<el-dialog v-model="importVisible" :title="t('importExport.importTitle')" width="480px" destroy-on-close>
<el-form label-width="100px">
<el-form-item :label="t('importExport.importMode')">
<el-select v-model="importMode">
<el-option :label="t('importExport.upsert')" value="upsert" />
<el-option :label="t('importExport.skip')" value="skip" />
<el-option :label="t('importExport.create')" value="create" />
</el-select>
</el-form-item>
<el-form-item :label="t('importExport.pending')">
<span>{{ t('importExport.pendingCount', { n: pendingItems.length }) }}</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="importVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="importing" @click="confirmImport">{{ t('importExport.startImport') }}</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { exportResource, importResource, parseImportFile } from '@/utils/importExport'
const { t } = useI18n()
const props = defineProps({
resource: { type: String, required: true },
exportFilename: { type: String, required: true },
})
const emit = defineEmits(['done'])
const exporting = ref(false)
const importing = ref(false)
const importVisible = ref(false)
const importMode = ref('upsert')
const pendingItems = ref([])
const fileInput = ref(null)
async function handleExport() {
exporting.value = true
try {
const data = await exportResource(`/${props.resource}`, props.exportFilename)
ElMessage.success(t('importExport.exportSuccess', { n: data.items?.length ?? 0 }))
} catch (e) {
ElMessage.error(e.response?.data?.error || t('message.exportFailed'))
} finally {
exporting.value = false
}
}
function openImport() {
fileInput.value?.click()
}
async function onFileChange(e) {
const file = e.target.files?.[0]
e.target.value = ''
if (!file) return
try {
pendingItems.value = await parseImportFile(file)
importMode.value = 'upsert'
importVisible.value = true
} catch (err) {
ElMessage.error(err.message || t('importExport.parseFailed'))
}
}
async function confirmImport() {
importing.value = true
try {
const result = await importResource(`/${props.resource}`, importMode.value, pendingItems.value)
const msg = t('importExport.importDone', {
created: result.created,
updated: result.updated,
skipped: result.skipped,
failed: result.failed,
})
if (result.failed > 0) {
ElMessage.warning(msg)
if (result.errors?.length) {
console.warn('import errors:', result.errors)
}
} else {
ElMessage.success(msg)
}
importVisible.value = false
emit('done')
} catch (e) {
ElMessage.error(e.response?.data?.error || t('message.importFailed'))
} finally {
importing.value = false
}
}
</script>
+18
View File
@@ -0,0 +1,18 @@
<template>
<div class="page-header">
<div>
<h1 class="page-title">{{ title }}</h1>
<p v-if="description" class="page-desc">{{ description }}</p>
</div>
<div v-if="$slots.actions" class="page-actions">
<slot name="actions" />
</div>
</div>
</template>
<script setup>
defineProps({
title: { type: String, required: true },
description: { type: String, default: '' },
})
</script>
+104
View File
@@ -0,0 +1,104 @@
<template>
<div class="proxy-headers-fields">
<el-form-item :label="t('ipForward.ipForward')">
<el-radio-group v-model="ipMode">
<el-radio label="replace">{{ ipForwardModeLabel('replace', t) }}</el-radio>
<el-radio label="append">{{ ipForwardModeLabel('append', t) }}</el-radio>
</el-radio-group>
<div class="form-hint">{{ modeHint }}</div>
</el-form-item>
<el-form-item :label="t('ipForward.customHeaders')">
<div class="header-list">
<div v-for="(row, idx) in headerRows" :key="idx" class="header-row">
<el-input v-model="row.key" :placeholder="t('ipForward.headerName')" @input="emitHeaders" />
<el-input v-model="row.value" :placeholder="t('ipForward.headerValue')" @input="emitHeaders" />
<el-button link type="danger" @click="removeRow(idx)">{{ t('common.delete') }}</el-button>
</div>
<el-button size="small" @click="addRow">{{ t('ipForward.addHeader') }}</el-button>
</div>
</el-form-item>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ipForwardModeHint, ipForwardModeLabel, normalizeIPForwardMode } from '@/utils/ipForward'
const { t } = useI18n()
const props = defineProps({
ipForwardMode: { type: String, default: 'replace' },
customHeaders: { type: String, default: '' },
})
const emit = defineEmits(['update:ipForwardMode', 'update:customHeaders'])
const headerRows = ref([])
function parseHeaders(raw) {
if (!raw || raw === '{}') return []
try {
const obj = JSON.parse(raw)
return Object.entries(obj).map(([key, value]) => ({ key, value: String(value) }))
} catch {
return []
}
}
function serializeHeaders(rows) {
const obj = {}
for (const row of rows) {
const key = row.key?.trim()
if (!key) continue
obj[key] = row.value ?? ''
}
return Object.keys(obj).length ? JSON.stringify(obj) : ''
}
function syncFromProps() {
headerRows.value = parseHeaders(props.customHeaders)
}
watch(() => props.customHeaders, syncFromProps, { immediate: true })
const ipMode = computed({
get() {
return normalizeIPForwardMode(props.ipForwardMode)
},
set(v) {
emit('update:ipForwardMode', v)
},
})
const modeHint = computed(() => ipForwardModeHint(ipMode.value, t))
function emitHeaders() {
emit('update:customHeaders', serializeHeaders(headerRows.value))
}
function addRow() {
headerRows.value.push({ key: '', value: '' })
emitHeaders()
}
function removeRow(idx) {
headerRows.value.splice(idx, 1)
emitHeaders()
}
</script>
<style scoped>
.header-list {
width: 100%;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.header-row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: var(--space-2);
align-items: center;
}
</style>
+75
View File
@@ -0,0 +1,75 @@
<template>
<el-dialog v-model="visible" :title="displayTitle" width="720px" destroy-on-close @open="load">
<el-table :data="items" v-loading="loading" size="small" class="table-scroll--lg">
<template v-if="type === 'tunnels'">
<el-table-column prop="name" :label="t('common.name')" />
<el-table-column prop="listen_port" :label="t('dashboard.listenPort')" width="80" />
<el-table-column :label="t('common.traffic')">
<template #default="{ row }">{{ formatBytes((row.inlet_flow || 0) + (row.export_flow || 0)) }}</template>
</el-table-column>
</template>
<template v-else-if="type === 'hosts'">
<el-table-column prop="host" :label="t('dashboard.host')" />
<el-table-column prop="target_addr" :label="t('common.target')" />
<el-table-column :label="t('common.traffic')">
<template #default="{ row }">{{ formatBytes((row.inlet_flow || 0) + (row.export_flow || 0)) }}</template>
</el-table-column>
</template>
<template v-else>
<el-table-column prop="ip" :label="t('dashboard.visitorIp')" min-width="120" />
<el-table-column prop="direct_ip" :label="t('dashboard.directIp')" min-width="120" />
<el-table-column prop="hit_count" :label="t('dashboard.hitCount')" width="80" />
<el-table-column prop="resource_type" :label="t('common.resource')" width="80" />
</template>
</el-table>
<div class="card-pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
@current-change="load"
/>
</div>
</el-dialog>
</template>
<script setup>
import { ref, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import api from '@/api'
import { formatBytes } from '@/utils/format'
const { t } = useI18n()
const props = defineProps({
modelValue: Boolean,
type: { type: String, required: true },
title: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue'])
const visible = ref(false)
const items = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(50)
const loading = ref(false)
const displayTitle = computed(() => props.title || t('rank.defaultTitle'))
watch(() => props.modelValue, (v) => { visible.value = v })
watch(visible, (v) => emit('update:modelValue', v))
async function load() {
loading.value = true
try {
const { data } = await api.get('/dashboard/rankings', {
params: { type: props.type, page: page.value, page_size: pageSize.value },
})
items.value = data.items || []
total.value = data.total || 0
} finally {
loading.value = false
}
}
</script>
+13
View File
@@ -0,0 +1,13 @@
<template>
<div class="stat-card">
<div class="stat-card__label">{{ label }}</div>
<div class="stat-card__value">{{ value }}</div>
</div>
</template>
<script setup>
defineProps({
label: { type: String, required: true },
value: { type: [String, Number], required: true },
})
</script>
+73
View File
@@ -0,0 +1,73 @@
<template>
<div class="visitor-auth">
<el-divider content-position="left">{{ t('visitorAuth.title') }}</el-divider>
<el-form-item :label="t('visitorAuth.enable')">
<el-switch v-model="enabled" />
</el-form-item>
<template v-if="enabled">
<el-form-item :label="t('visitorAuth.allowVisitors')">
<el-radio-group v-model="bindMode">
<el-radio value="all">{{ t('visitorAuth.allVisitors') }}</el-radio>
<el-radio value="selected">{{ t('visitorAuth.selectedVisitors') }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="bindMode === 'selected'" :label="t('visitorAuth.selectVisitors')">
<el-select v-model="visitorIds" multiple filterable :placeholder="t('visitorAuth.selectVisitors')">
<el-option v-for="v in visitors" :key="v.id" :label="v.username" :value="v.id" />
</el-select>
</el-form-item>
<el-form-item :label="t('visitorAuth.ttl')">
<el-input-number v-model="ttlSeconds" :min="300" :max="604800" :step="3600" class="w-full" />
<div class="form-hint">{{ t('visitorAuth.ttlHint') }}</div>
</el-form-item>
<el-alert
v-if="tunnelHint"
type="info"
:closable="false"
show-icon
:title="t('visitorAuth.tunnelHint')"
class="u-mb-4"
/>
</template>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps({
visitorAuthEnabled: Boolean,
visitorBindMode: { type: String, default: 'all' },
visitorTtlSeconds: { type: Number, default: 7200 },
visitorIds: { type: Array, default: () => [] },
visitors: { type: Array, default: () => [] },
tunnelHint: Boolean,
})
const emit = defineEmits([
'update:visitorAuthEnabled',
'update:visitorBindMode',
'update:visitorTtlSeconds',
'update:visitorIds',
])
const enabled = computed({
get: () => props.visitorAuthEnabled,
set: (v) => emit('update:visitorAuthEnabled', v),
})
const bindMode = computed({
get: () => props.visitorBindMode || 'all',
set: (v) => emit('update:visitorBindMode', v),
})
const ttlSeconds = computed({
get: () => props.visitorTtlSeconds || 7200,
set: (v) => emit('update:visitorTtlSeconds', v),
})
const visitorIds = computed({
get: () => props.visitorIds || [],
set: (v) => emit('update:visitorIds', v),
})
</script>
+26
View File
@@ -0,0 +1,26 @@
import { ref } from 'vue'
import { formatChartTime } from '@/utils/datetime'
const MAX_POINTS = 36
export function useMetricHistory() {
const labels = ref([])
const cpu = ref([])
const memory = ref([])
const inlet = ref([])
const exportFlow = ref([])
function trim(arr) {
return arr.length > MAX_POINTS ? arr.slice(-MAX_POINTS) : arr
}
function record(snapshot) {
labels.value = trim([...labels.value, formatChartTime()])
cpu.value = trim([...cpu.value, Number(snapshot.cpu_percent?.toFixed?.(1) ?? 0)])
memory.value = trim([...memory.value, Number(snapshot.mem_percent?.toFixed?.(1) ?? 0)])
inlet.value = trim([...inlet.value, snapshot.inlet_flow ?? 0])
exportFlow.value = trim([...exportFlow.value, snapshot.export_flow ?? 0])
}
return { labels, cpu, memory, inlet, exportFlow, record }
}
+29
View File
@@ -0,0 +1,29 @@
import { createI18n } from 'vue-i18n'
import zhCN from '@/locales/zh-CN'
import en from '@/locales/en'
const LOCALE_KEY = 'wormhole-locale'
function detectLocale() {
const saved = localStorage.getItem(LOCALE_KEY)
if (saved === 'zh-CN' || saved === 'en') return saved
const lang = navigator.language || ''
return lang.startsWith('zh') ? 'zh-CN' : 'en'
}
export const i18n = createI18n({
legacy: false,
locale: detectLocale(),
fallbackLocale: 'zh-CN',
messages: { 'zh-CN': zhCN, en },
})
export function setLocale(locale) {
i18n.global.locale.value = locale
localStorage.setItem(LOCALE_KEY, locale)
document.documentElement.lang = locale === 'zh-CN' ? 'zh-CN' : 'en'
}
setLocale(i18n.global.locale.value)
export default i18n
+143
View File
@@ -0,0 +1,143 @@
<template>
<div class="layout">
<aside class="sidebar custom-scrollbar">
<div class="sidebar-brand">
<div class="brand-icon">
<AppLogo :size="20" />
</div>
<span class="brand-name">{{ t('app.name') }}</span>
</div>
<template v-for="group in navGroups" :key="group.labelKey">
<template v-if="group.visible">
<div class="nav-group-label">{{ t(group.labelKey) }}</div>
<router-link
v-for="item in group.items"
:key="item.path"
:to="item.path"
class="nav-item"
:class="{ active: route.path === item.path }"
>
<el-icon :size="16"><component :is="item.icon" /></el-icon>
{{ t(item.labelKey) }}
</router-link>
</template>
</template>
</aside>
<div class="main-area">
<header class="topbar">
<span class="topbar-title">{{ pageTitle }}</span>
<div class="topbar-user">
<el-select
:model-value="locale"
class="toolbar-select"
size="small"
@change="onLocaleChange"
>
<el-option :label="t('common.zh')" value="zh-CN" />
<el-option :label="t('common.en')" value="en" />
</el-select>
<span>{{ user?.username }}</span>
<el-button text @click="logout">{{ t('common.logout') }}</el-button>
</div>
</header>
<main class="main-content custom-scrollbar">
<router-view />
</main>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import {
Odometer,
Link,
Connection,
Share,
Monitor,
Lock,
User,
Setting,
} from '@element-plus/icons-vue'
import AppLogo from '@/components/AppLogo.vue'
import { setLocale } from '@/i18n'
const { t, locale } = useI18n()
const route = useRoute()
const router = useRouter()
const topbarKeys = {
'/dashboard': 'topbar.dashboard',
'/clients': 'topbar.clients',
'/tunnels': 'topbar.tunnels',
'/hosts': 'topbar.hosts',
'/domain-forwards': 'topbar.domainForwards',
'/security/ip-rules': 'topbar.ipRules',
'/security/request-ips': 'topbar.requestIPs',
'/users': 'topbar.users',
'/settings': 'topbar.settings',
}
const user = computed(() => {
try {
return JSON.parse(localStorage.getItem('user') || '{}')
} catch {
return {}
}
})
const isAdmin = computed(() => user.value?.role === 'admin')
const pageTitle = computed(() => t(topbarKeys[route.path] || 'topbar.fallback'))
const navGroups = computed(() => [
{
labelKey: 'nav.groups.overview',
visible: true,
items: [{ path: '/dashboard', labelKey: 'nav.dashboard', icon: Odometer }],
},
{
labelKey: 'nav.groups.forward',
visible: isAdmin.value,
items: [{ path: '/domain-forwards', labelKey: 'nav.domainForwards', icon: Link }],
},
{
labelKey: 'nav.groups.tunnel',
visible: true,
items: [
{ path: '/clients', labelKey: 'nav.clients', icon: Connection },
{ path: '/tunnels', labelKey: 'nav.tunnels', icon: Share },
{ path: '/hosts', labelKey: 'nav.hosts', icon: Monitor },
],
},
{
labelKey: 'nav.groups.security',
visible: true,
items: [
{ path: '/security/ip-rules', labelKey: 'nav.ipRules', icon: Lock },
{ path: '/security/request-ips', labelKey: 'nav.requestIPs', icon: Monitor },
],
},
{
labelKey: 'nav.groups.system',
visible: isAdmin.value,
items: [
{ path: '/users', labelKey: 'nav.users', icon: User },
{ path: '/settings', labelKey: 'nav.settings', icon: Setting },
],
},
])
function onLocaleChange(value) {
setLocale(value)
}
function logout() {
localStorage.removeItem('token')
localStorage.removeItem('user')
router.push('/login')
}
</script>
+358
View File
@@ -0,0 +1,358 @@
export default {
app: {
name: 'Wormhole',
tagline: 'Intranet tunneling + reverse proxy gateway',
},
common: {
refresh: 'Refresh',
save: 'Save',
cancel: 'Cancel',
close: 'Close',
search: 'Search',
delete: 'Delete',
edit: 'Edit',
create: 'Create',
confirm: 'Confirm',
actions: 'Actions',
enabled: 'Enabled',
disabled: 'Disabled',
yes: 'Yes',
no: 'No',
name: 'Name',
status: 'Status',
id: 'ID',
optional: 'Optional',
loading: 'Loading…',
viewAll: 'View all',
logout: 'Sign out',
language: 'Language',
zh: '中文',
en: 'English',
hint: 'Notice',
export: 'Export',
import: 'Import',
start: 'Start',
stop: 'Stop',
pause: 'Pause',
resume: 'Resume',
block: 'Block',
traffic: 'Traffic',
target: 'Target',
client: 'Client',
mode: 'Mode',
port: 'Port',
prefix: 'Prefix',
resource: 'Resource',
method: 'Method',
path: 'Path',
time: 'Time',
type: 'Type',
running: 'Running',
stopped: 'Stopped',
paused: 'Paused',
on: 'On',
off: 'Off',
online: 'Online',
offline: 'Offline',
selectClient: 'Please select a client',
dash: '-',
},
nav: {
groups: {
overview: 'Overview',
forward: 'Forward',
tunnel: 'Tunnel',
security: 'Security',
system: 'System',
},
dashboard: 'Dashboard',
domainForwards: 'Domain forward',
clients: 'Clients',
tunnels: 'Tunnels',
hosts: 'Hosts',
ipRules: 'IP rules',
requestIPs: 'Request IPs',
users: 'Visitors',
settings: 'Settings',
},
topbar: {
dashboard: 'Dashboard',
clients: 'Client management',
tunnels: 'Tunnel management',
hosts: 'Host routing',
domainForwards: 'Domain forward',
ipRules: 'IP rules',
requestIPs: 'Request IP monitor',
users: 'Visitor users',
settings: 'System settings',
fallback: 'Wormhole',
},
login: {
title: 'Sign in',
username: 'Username',
password: 'Password',
submit: 'Sign in',
hint: 'Default account admin / admin',
lockDefault: 'Too many login attempts. Please try again later.',
lockMinutes: 'Too many login attempts. Try again in {n} minute(s).',
lockSeconds: 'Too many login attempts. Try again in {n} second(s).',
},
dashboard: {
title: 'Dashboard',
description: 'System status, traffic and access monitoring',
openapi: 'OpenAPI',
statClientTotal: 'Total clients',
statClientOnline: 'Online clients',
statTunnelRunning: 'Running tunnels',
statHostTotal: 'Total hosts',
systemResources: 'System resources',
trafficOverview: 'Traffic overview',
cpu: 'CPU',
memory: 'Memory',
load: 'Load',
inlet: 'Inbound',
export: 'Outbound',
resourceTrend: 'Resource trend',
trafficTrend: 'Traffic trend',
tunnelTop10: 'Top 10 tunnels by traffic',
hostTop10: 'Top 10 hosts by traffic',
ipTop10: 'Top 10 request IPs',
rankTunnels: 'Tunnel traffic ranking',
rankHosts: 'Host traffic ranking',
rankIps: 'Request IP ranking',
visitorIp: 'Visitor IP',
directIp: 'Direct IP',
hitCount: 'Hits',
host: 'Host',
listenPort: 'Port',
},
clients: {
title: 'Client management',
description: 'Manage Agent connections and verify_key',
add: 'Add client',
edit: 'Edit client',
verifyKey: 'Verify Key',
rateLimit: 'Rate limit (KB/s)',
maxConn: 'Max connections',
confirmDelete: 'Delete this client?',
},
tunnels: {
title: 'Tunnel management',
description: 'Configure TCP/UDP port forwarding tunnels',
add: 'Add tunnel',
edit: 'Edit tunnel',
searchPlaceholder: 'Search name, port, target',
listenPort: 'Listen port',
targetAddr: 'Target address',
visitorAuth: 'Visitor auth',
confirmDelete: 'Delete this tunnel?',
tcp: 'TCP',
udp: 'UDP',
},
hosts: {
title: 'Host routing',
description: 'HTTP/HTTPS reverse proxy and Host routing',
add: 'Add host',
edit: 'Edit host',
searchPlaceholder: 'Search host, target',
host: 'Host',
location: 'Path prefix',
autoHttps: 'Auto HTTPS',
visitorAuth: 'Visitor auth',
confirmDelete: 'Delete this host?',
},
domainForwards: {
title: 'Domain forward',
description: 'Redirect or reverse-proxy source domains to target URLs without Agent',
add: 'Add forward',
edit: 'Edit forward',
searchPlaceholder: 'Search source host, target',
sourceHost: 'Source host',
sourceLocation: 'Path prefix',
scheme: 'Scheme',
schemeAll: 'All',
forwardMode: 'Forward mode',
redirect: '302/301 redirect',
proxy: 'Reverse proxy',
targetUrl: 'Target URL',
redirectMode: 'Redirect strategy',
preservePath: 'Preserve path and query',
fixedTarget: 'Fixed target URL',
statusCode: 'Status code',
outboundProxy: 'Outbound proxy',
httpDirect: 'HTTP direct',
httpProxy: 'HTTP proxy',
socks5: 'SOCKS5 proxy',
httpProxyAddr: 'HTTP proxy address',
socks5Addr: 'SOCKS5 address',
username: 'Username',
password: 'Password',
preserveHost: 'Preserve Host',
preserveHostHint: 'Send source host as Host header to upstream when enabled',
pluginChain: 'Plugin chain',
pluginChainHint: 'Process responses in order; HTTP only, WebSocket bypasses plugins',
plugins: 'Plugins',
proxyCol: 'Proxy',
direct: 'Direct',
httpProxyShort: 'HTTP proxy',
modeProxy: 'Proxy',
modeRedirect: 'Redirect',
pluginDisabled: 'Disabled',
confirmDelete: 'Delete this forward rule?',
},
ipRules: {
title: 'IP rules',
description: 'Allow / deny lists and CIDR access control',
add: 'Add rule',
edit: 'Edit rule',
pattern: 'Pattern',
patternPlaceholder: 'IP / CIDR / regex',
ruleType: 'Type',
allow: 'Allow',
deny: 'Deny',
resourceType: 'Resource type',
global: 'Global',
tunnel: 'Tunnel',
host: 'Host',
resourceId: 'Resource ID',
confirmDelete: 'Delete this rule?',
},
requestIPs: {
title: 'Request IP monitor',
description: 'Visitor IP (X-Forwarded-For preferred) and direct IP',
searchPlaceholder: 'Search IP',
resourceType: 'Resource type',
resourceId: 'Resource ID',
hitCount: 'Hit count',
lastSeen: 'Last seen',
confirmBlock: 'Add this IP to the block list?',
},
users: {
title: 'Visitor users',
description: 'Manage visitor accounts for protected hosts/tunnels',
add: 'Add visitor',
edit: 'Edit visitor',
username: 'Username',
password: 'Password',
passwordPlaceholderEdit: 'Leave blank to keep unchanged',
accessLogs: 'Access logs',
accessLogsTitle: 'Access logs · {name}',
logsHint: 'Latest {n} entries (max 200, static assets excluded)',
login: 'Login',
visit: 'Visit',
confirmDelete: 'Delete this visitor?',
},
settings: {
title: 'System settings',
description: 'Server runtime parameters (some changes require restart)',
listenPorts: 'Listen ports',
bridgeAddr: 'Bridge address',
httpAddr: 'Admin API / Web',
httpProxyPort: 'HTTP proxy port',
httpsProxyPort: 'HTTPS proxy port',
tlsCert: 'TLS certificate',
tlsKey: 'TLS private key',
tlsOptional: 'Optional, leave blank to disable',
authSecurity: 'Auth & security',
jwtSecret: 'JWT secret',
jwtSecretPlaceholder: 'Leave blank to keep unchanged',
tokenTtl: 'Token TTL (hours)',
loginMaxAttempts: 'Max login attempts',
loginWindow: 'Attempt window (minutes)',
loginLockout: 'Lockout duration (minutes)',
adminAccount: 'Admin account',
currentPassword: 'Current password',
newPassword: 'New password',
confirmPassword: 'Confirm password',
changePassword: 'Change admin password',
metrics: 'Metrics',
flushInterval: 'Flush interval (seconds)',
apiKey: 'API Key',
apiKeyHint: 'For programmatic API access (port 8529), use header {header}. Docs: {doc}',
createApiKey: 'Create API Key',
createApiKeyTitle: 'Create API Key',
apiKeyNamePlaceholder: 'e.g. CI/CD',
apiKeySave: 'Save this key: {key}',
fillPassword: 'Please enter current and new password',
passwordMismatch: 'New passwords do not match',
enterApiKeyName: 'Please enter a name',
},
ipForward: {
replace: {
label: 'Replace real IP',
hint: 'Overwrite X-Forwarded-For and X-Real-IP with visitor IP, no proxy chain preserved',
},
append: {
label: 'Standard forward headers',
hint: 'Append X-Forwarded-For and set X-Real-IP per reverse-proxy convention (like Nginx proxy_add_x_forwarded_for)',
},
ipForward: 'IP forwarding',
customHeaders: 'Custom headers',
headerName: 'Header name',
headerValue: 'Header value',
addHeader: 'Add header',
},
visitorAuth: {
title: 'Visitor auth',
enable: 'Enable auth',
allowVisitors: 'Allowed visitors',
allVisitors: 'All visitors',
selectedVisitors: 'Selected visitors',
selectVisitors: 'Select visitor users',
ttl: 'Credential TTL',
ttlHint: 'Seconds; browser stores credential after successful login',
tunnelHint: 'With visitor auth, the port serves HTTP (for web apps). For SSH/TCP use host reverse proxy.',
},
importExport: {
importTitle: 'Bulk import',
importMode: 'Import mode',
upsert: 'Upsert (update if exists)',
skip: 'Skip existing (new only)',
create: 'Create all (skip conflicts)',
pending: 'Pending import',
pendingCount: '{n} item(s)',
startImport: 'Start import',
exportSuccess: 'Exported {n} item(s)',
importDone: 'Import done: created {created}, updated {updated}, skipped {skipped}, failed {failed}',
parseFailed: 'Failed to parse file',
},
forwardPlugin: {
addPlugin: 'Add plugin',
moveUp: 'Move up',
moveDown: 'Move down',
find: 'Find',
replaceWith: 'Replace with',
addRule: 'Add replace rule',
mimeTypes: 'MIME types',
removeShort: 'Del',
mbHint: 'MB (0 disables cache)',
ttlSec: 'seconds TTL',
scriptPlaceholder: '<script>...</script>',
fromDomain: 'From domain, e.g. .old.com',
toDomain: 'To domain, e.g. .new.com',
},
clientSelect: {
placeholder: 'Select client',
label: '{name} (#{id}) · {status}',
},
rank: {
defaultTitle: 'Details',
},
message: {
loginSuccess: 'Signed in',
loginFailed: 'Sign in failed',
loginRateLimit: 'Too many login attempts',
saveSuccess: 'Saved',
saveFailed: 'Save failed',
deleteSuccess: 'Deleted',
exportFailed: 'Export failed',
importFailed: 'Import failed',
loadFailed: 'Load failed',
saved: 'Saved',
savedRestart: 'Saved. Restart wormhole server for listen port changes to take effect.',
passwordUpdated: 'Admin password updated',
passwordChangeFailed: 'Password change failed',
apiKeyCreated: 'Created. Copy and save the key.',
blockSuccess: 'Added to block list',
},
}
+358
View File
@@ -0,0 +1,358 @@
export default {
app: {
name: 'Wormhole',
tagline: '内网穿透 + 反向代理网关',
},
common: {
refresh: '刷新',
save: '保存',
cancel: '取消',
close: '关闭',
search: '搜索',
delete: '删除',
edit: '编辑',
create: '创建',
confirm: '确认',
actions: '操作',
enabled: '启用',
disabled: '禁用',
yes: '是',
no: '否',
name: '名称',
status: '状态',
id: 'ID',
optional: '可选',
loading: '加载中…',
viewAll: '查看全部',
logout: '退出',
language: '语言',
zh: '中文',
en: 'English',
hint: '提示',
export: '导出',
import: '导入',
start: '启动',
stop: '停止',
pause: '暂停',
resume: '恢复',
block: '拉黑',
traffic: '流量',
target: '目标',
client: '客户端',
mode: '模式',
port: '端口',
prefix: '前缀',
resource: '资源',
method: '方法',
path: '路径',
time: '时间',
type: '类型',
running: '运行中',
stopped: '已停止',
paused: '已暂停',
on: '已开启',
off: '未开启',
online: '在线',
offline: '离线',
selectClient: '请选择客户端',
dash: '-',
},
nav: {
groups: {
overview: '概览',
forward: '转发',
tunnel: '穿透',
security: '安全',
system: '系统',
},
dashboard: '仪表盘',
domainForwards: '域名转发',
clients: '客户端',
tunnels: '隧道',
hosts: '域名',
ipRules: 'IP 规则',
requestIPs: '请求 IP',
users: '访客用户',
settings: '系统设置',
},
topbar: {
dashboard: '仪表盘',
clients: '客户端管理',
tunnels: '隧道管理',
hosts: '域名解析',
domainForwards: '域名转发',
ipRules: 'IP 规则',
requestIPs: '请求 IP 监控',
users: '访客用户',
settings: '系统设置',
fallback: 'Wormhole',
},
login: {
title: '登录',
username: '用户名',
password: '密码',
submit: '登录',
hint: '默认账号 admin / admin',
lockDefault: '登录尝试次数过多,请稍后再试',
lockMinutes: '登录尝试次数过多,请 {n} 分钟后再试',
lockSeconds: '登录尝试次数过多,请 {n} 秒后再试',
},
dashboard: {
title: '仪表盘',
description: '系统运行状态、流量与访问监控概览',
openapi: 'OpenAPI',
statClientTotal: '客户端总数',
statClientOnline: '在线客户端',
statTunnelRunning: '运行隧道',
statHostTotal: '域名数量',
systemResources: '系统资源',
trafficOverview: '流量概览',
cpu: 'CPU',
memory: '内存',
load: '负载',
inlet: '入站',
export: '出站',
resourceTrend: '系统资源趋势',
trafficTrend: '流量趋势',
tunnelTop10: '隧道流量 Top10',
hostTop10: '域名流量 Top10',
ipTop10: '请求 IP Top10',
rankTunnels: '隧道流量排行',
rankHosts: '域名流量排行',
rankIps: '请求 IP 排行',
visitorIp: '访客 IP',
directIp: '直连 IP',
hitCount: '次数',
host: '域名',
listenPort: '端口',
},
clients: {
title: '客户端管理',
description: '管理 Agent 连接与 verify_key',
add: '新增客户端',
edit: '编辑客户端',
verifyKey: 'Verify Key',
rateLimit: '限速(KB/s)',
maxConn: '最大连接',
confirmDelete: '确认删除该客户端?',
},
tunnels: {
title: '隧道管理',
description: '配置 TCP/UDP 端口转发隧道',
add: '新增隧道',
edit: '编辑隧道',
searchPlaceholder: '搜索名称、端口、目标',
listenPort: '监听端口',
targetAddr: '目标地址',
visitorAuth: '访客登录',
confirmDelete: '确认删除该隧道?',
tcp: 'TCP',
udp: 'UDP',
},
hosts: {
title: '域名解析',
description: 'HTTP/HTTPS 反向代理与 Host 路由',
add: '新增域名',
edit: '编辑域名',
searchPlaceholder: '搜索域名、目标',
host: '域名',
location: '路径前缀',
autoHttps: '自动 HTTPS',
visitorAuth: '访客登录',
confirmDelete: '确认删除该域名?',
},
domainForwards: {
title: '域名转发',
description: '将源域名跳转或反代到目标 URL,无需 Agent(与内网穿透独立)',
add: '新增转发',
edit: '编辑转发',
searchPlaceholder: '搜索源域名、目标',
sourceHost: '源域名',
sourceLocation: '路径前缀',
scheme: '协议',
schemeAll: '全部',
forwardMode: '转发模式',
redirect: '302/301 跳转',
proxy: '反向代理',
targetUrl: '目标 URL',
redirectMode: '跳转策略',
preservePath: '保留路径与参数',
fixedTarget: '固定目标地址',
statusCode: '状态码',
outboundProxy: '出站代理',
httpDirect: 'HTTP 直连',
httpProxy: 'HTTP 代理',
socks5: 'SOCKS5 代理',
httpProxyAddr: 'HTTP 代理地址',
socks5Addr: 'SOCKS5 地址',
username: '用户名',
password: '密码',
preserveHost: '保留 Host',
preserveHostHint: '开启后将源域名作为 Host 头发给上游',
pluginChain: '插件链',
pluginChainHint: '按顺序处理响应;仅对普通 HTTP 生效,WebSocket 不走插件',
plugins: '插件',
proxyCol: '代理',
direct: '直连',
httpProxyShort: 'HTTP代理',
modeProxy: '反代',
modeRedirect: '跳转',
pluginDisabled: '未启用',
confirmDelete: '确认删除该转发规则?',
},
ipRules: {
title: 'IP 规则',
description: '白名单 / 黑名单 / CIDR 访问控制',
add: '新增规则',
edit: '编辑规则',
pattern: '匹配模式',
patternPlaceholder: 'IP / CIDR / 正则',
ruleType: '类型',
allow: '白名单',
deny: '黑名单',
resourceType: '资源类型',
global: '全局',
tunnel: '隧道',
host: '域名',
resourceId: '资源ID',
confirmDelete: '确认删除该规则?',
},
requestIPs: {
title: '请求 IP 监控',
description: '访客真实 IP(优先 X-Forwarded-For)与直连 IP',
searchPlaceholder: '搜索 IP',
resourceType: '资源类型',
resourceId: '资源 ID',
hitCount: '访问次数',
lastSeen: '最后访问',
confirmBlock: '确认将该 IP 加入黑名单?',
},
users: {
title: '访客用户',
description: '管理可访问受保护域名/隧道的访客账号',
add: '新增访客',
edit: '编辑访客',
username: '用户名',
password: '密码',
passwordPlaceholderEdit: '留空不修改',
accessLogs: '访问记录',
accessLogsTitle: '访问记录 · {name}',
logsHint: '最近 {n} 条(最多保留 200 条,不含静态资源请求)',
login: '登录',
visit: '访问',
confirmDelete: '确认删除该访客?',
},
settings: {
title: '系统设置',
description: '服务端运行参数(保存后部分项需重启服务生效)',
listenPorts: '监听端口',
bridgeAddr: 'Bridge 地址',
httpAddr: '管理 API / Web',
httpProxyPort: 'HTTP 反代端口',
httpsProxyPort: 'HTTPS 反代端口',
tlsCert: 'TLS 证书',
tlsKey: 'TLS 私钥',
tlsOptional: '可选,留空不使用',
authSecurity: '认证与安全',
jwtSecret: 'JWT 密钥',
jwtSecretPlaceholder: '留空则不修改',
tokenTtl: 'Token 有效期(h)',
loginMaxAttempts: '登录最大尝试',
loginWindow: '统计窗口(分钟)',
loginLockout: '锁定时长(分钟)',
adminAccount: '管理员账号',
currentPassword: '当前密码',
newPassword: '新密码',
confirmPassword: '确认新密码',
changePassword: '修改管理员密码',
metrics: '指标',
flushInterval: '刷新间隔(秒)',
apiKey: 'API Key',
apiKeyHint: '用于程序化访问 API(端口 8529),请求头携带 {header}。文档:{doc}',
createApiKey: '创建 API Key',
createApiKeyTitle: '创建 API Key',
apiKeyNamePlaceholder: '如 CI/CD',
apiKeySave: '请保存:{key}',
fillPassword: '请填写当前密码和新密码',
passwordMismatch: '两次输入的新密码不一致',
enterApiKeyName: '请输入名称',
},
ipForward: {
replace: {
label: '覆盖真实 IP',
hint: '将 X-Forwarded-For、X-Real-IP 覆盖为访客真实 IP,不保留已有代理链',
},
append: {
label: '标准转发头',
hint: '按反向代理规范追加 X-Forwarded-For,并设置 X-Real-IP(等同 Nginx proxy_add_x_forwarded_for',
},
ipForward: 'IP 转发',
customHeaders: '自定义请求头',
headerName: 'Header 名称',
headerValue: 'Header 值',
addHeader: '添加请求头',
},
visitorAuth: {
title: '访客登录',
enable: '开启验证',
allowVisitors: '允许访客',
allVisitors: '全部访客',
selectedVisitors: '指定访客',
selectVisitors: '选择访客用户',
ttl: '凭证有效期',
ttlHint: '单位:秒,登录成功后浏览器将保存凭证',
tunnelHint: '开启访客登录后,该端口将以 HTTP 方式提供服务(适用于 Web 应用)。SSH 等 TCP 协议请使用域名反代。',
},
importExport: {
importTitle: '批量导入',
importMode: '导入模式',
upsert: '覆盖更新(已存在则更新)',
skip: '跳过已存在(仅导入新配置)',
create: '全部新建(冲突则跳过)',
pending: '待导入',
pendingCount: '{n} 条配置',
startImport: '开始导入',
exportSuccess: '已导出 {n} 条配置',
importDone: '导入完成:新增 {created},更新 {updated},跳过 {skipped},失败 {failed}',
parseFailed: '解析文件失败',
},
forwardPlugin: {
addPlugin: '添加插件',
moveUp: '上移',
moveDown: '下移',
find: '查找',
replaceWith: '替换为',
addRule: '添加替换规则',
mimeTypes: 'MIME 类型',
removeShort: '删',
mbHint: 'MB0 表示禁用缓存)',
ttlSec: '秒 TTL',
scriptPlaceholder: '<script>...</script>',
fromDomain: '原 Domain,如 .old.com',
toDomain: '新 Domain,如 .new.com',
},
clientSelect: {
placeholder: '选择客户端',
label: '{name} (#{id}) · {status}',
},
rank: {
defaultTitle: '详情',
},
message: {
loginSuccess: '登录成功',
loginFailed: '登录失败',
loginRateLimit: '登录尝试次数过多',
saveSuccess: '保存成功',
saveFailed: '保存失败',
deleteSuccess: '已删除',
exportFailed: '导出失败',
importFailed: '导入失败',
loadFailed: '加载失败',
saved: '已保存',
savedRestart: '已保存,监听端口变更需重启 wormhole server 后生效',
passwordUpdated: '管理员密码已更新',
passwordChangeFailed: '修改失败',
apiKeyCreated: '已创建,请复制保存',
blockSuccess: '已加入黑名单',
},
}
+17
View File
@@ -0,0 +1,17 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import enLocale from 'element-plus/dist/locale/en.mjs'
import 'element-plus/dist/index.css'
import '@/styles/theme.css'
import App from './App.vue'
import router from './router'
import i18n from '@/i18n'
const epLocales = { 'zh-CN': zhCn, en: enLocale }
createApp(App)
.use(ElementPlus, { locale: epLocales[i18n.global.locale.value] })
.use(i18n)
.use(router)
.mount('#app')
+50
View File
@@ -0,0 +1,50 @@
import { createRouter, createWebHistory } from 'vue-router'
import MainLayout from '@/layouts/MainLayout.vue'
import Login from '@/views/Login.vue'
import Dashboard from '@/views/Dashboard.vue'
import Clients from '@/views/Clients.vue'
import Tunnels from '@/views/Tunnels.vue'
import Hosts from '@/views/Hosts.vue'
import DomainForwards from '@/views/DomainForwards.vue'
import IPRules from '@/views/IPRules.vue'
import RequestIPs from '@/views/RequestIPs.vue'
import Users from '@/views/Users.vue'
import Settings from '@/views/Settings.vue'
const routes = [
{ path: '/login', component: Login },
{
path: '/',
component: MainLayout,
redirect: '/dashboard',
children: [
{ path: 'dashboard', component: Dashboard },
{ path: 'clients', component: Clients },
{ path: 'tunnels', component: Tunnels },
{ path: 'hosts', component: Hosts },
{ path: 'domain-forwards', component: DomainForwards },
{ path: 'security/ip-rules', component: IPRules },
{ path: 'security/request-ips', component: RequestIPs },
{ path: 'users', component: Users },
{ path: 'settings', component: Settings },
],
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (to.path !== '/login' && !token) {
next('/login')
} else if (to.path === '/login' && token) {
next('/dashboard')
} else {
next()
}
})
export default router
+557
View File
@@ -0,0 +1,557 @@
:root {
--background: #f4f4f5;
--foreground: #18181b;
--card: #ffffff;
--card-foreground: #18181b;
--primary: #0f7a62;
--primary-hover: #0d6b56;
--primary-foreground: #fafafa;
--muted: #f4f4f5;
--muted-foreground: #71717a;
--border: #e4e4e7;
--input: #e4e4e7;
--destructive: #dc2626;
--success: #047857;
--warning: #b45309;
--danger: #dc2626;
--info: #a1a1aa;
--sidebar: rgba(250, 250, 250, 0.85);
--sidebar-foreground: #18181b;
--sidebar-accent: #f4f4f5;
--sidebar-border: #e4e4e7;
--sidebar-width: 15rem;
--radius: 0.5rem;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--el-color-primary: #0f7a62;
--el-color-primary-light-3: #4da08c;
--el-color-primary-light-5: #76b8a8;
--el-color-primary-light-7: #a0d0c4;
--el-color-primary-light-8: #b8dcd2;
--el-color-primary-light-9: #d0e8e1;
--el-color-primary-dark-2: #0c624e;
--el-color-success: #047857;
--el-color-warning: #b45309;
--el-color-danger: #dc2626;
--el-color-info: #a1a1aa;
--el-border-radius-base: 0.5rem;
--el-border-color: #e4e4e7;
--el-bg-color-page: #f4f4f5;
--el-text-color-primary: #18181b;
--el-text-color-regular: #3f3f46;
--el-text-color-secondary: #71717a;
--el-font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif;
}
* { box-sizing: border-box; }
html, body, #app {
margin: 0;
padding: 0;
height: 100%;
font-size: 14px;
font-family: var(--el-font-family);
background: var(--background);
color: var(--foreground);
-webkit-font-smoothing: antialiased;
}
.layout {
display: flex;
height: 100vh;
overflow: hidden;
}
.sidebar {
width: var(--sidebar-width);
flex-shrink: 0;
background: var(--sidebar);
border-right: 1px solid var(--sidebar-border);
backdrop-filter: blur(8px);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.sidebar-brand {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-5) var(--space-4);
}
.brand-icon {
width: 32px;
height: 32px;
border-radius: var(--radius);
background: var(--primary);
color: var(--primary-foreground);
display: flex;
align-items: center;
justify-content: center;
}
.brand-name {
font-size: 1.125rem;
font-weight: 600;
}
.nav-group-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted-foreground);
padding: var(--space-4) var(--space-4) var(--space-2);
}
.nav-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
margin: 0 var(--space-2);
border-radius: var(--radius);
color: var(--muted-foreground);
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
}
.nav-item:hover { background: var(--sidebar-accent); }
.nav-item.active {
background: var(--sidebar-accent);
color: var(--primary);
font-weight: 600;
}
.main-area {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.topbar {
height: 52px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--space-6);
background: var(--card);
border-bottom: 1px solid var(--border);
}
.topbar-title {
font-size: 0.875rem;
color: var(--muted-foreground);
}
.topbar-user {
display: flex;
align-items: center;
gap: var(--space-3);
font-size: 0.875rem;
color: var(--muted-foreground);
}
.main-content {
flex: 1;
overflow-y: auto;
padding: var(--space-6);
background: var(--background);
}
.page {
max-width: 1400px;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: var(--space-6);
gap: var(--space-4);
}
.page-actions {
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
flex-wrap: wrap;
}
.page-title {
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.02em;
margin: 0 0 var(--space-1);
}
.page-desc {
font-size: 0.875rem;
color: var(--muted-foreground);
margin: 0;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-4);
margin-bottom: var(--space-6);
}
.stat-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-5);
}
.stat-card__label {
font-size: 0.875rem;
color: var(--muted-foreground);
margin-bottom: var(--space-2);
}
.stat-card__value {
font-size: 1.75rem;
font-weight: 600;
}
.app-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: var(--space-4);
}
.page > .app-card:last-child,
.page > .content-grid:last-child {
margin-bottom: 0;
}
.app-card__header {
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border);
}
.app-card__title {
font-size: 0.9375rem;
font-weight: 600;
margin: 0;
}
.app-card__body--padded { padding: var(--space-5); }
.content-grid {
display: grid;
gap: var(--space-4);
margin-bottom: var(--space-6);
}
.content-grid--2-1 { grid-template-columns: 1.4fr 1fr; }
.content-grid--2 { grid-template-columns: 1fr 1fr; }
.content-grid--3 { grid-template-columns: repeat(3, 1fr); }
.card-toolbar {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-4) var(--space-5) 0;
margin-bottom: var(--space-4);
flex-wrap: wrap;
}
.card-toolbar--desc {
align-items: flex-start;
}
.card-pager {
padding: var(--space-4) var(--space-5);
display: flex;
justify-content: flex-end;
}
.card-footer-hint {
padding: 0 var(--space-5) var(--space-3);
margin: 0;
}
.card-footer-hint + .card-pager {
padding-top: var(--space-3);
}
.card-inset-block {
margin: 0 var(--space-5) var(--space-4);
padding: var(--space-3);
background: var(--muted);
border-radius: var(--radius);
font-size: 0.875rem;
}
.tabs-bar {
padding: 0 var(--space-5);
border-bottom: 1px solid var(--border);
}
.tabs-bar .el-tabs__header { margin-bottom: 0; }
.page-toolbar {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-4);
flex-wrap: wrap;
}
.inline-actions {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.card-header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.form-hint {
margin-top: 6px;
font-size: 0.75rem;
color: var(--muted-foreground);
}
.form-hint--flush { margin-top: 0; }
.u-mt-3 { margin-top: var(--space-3); }
.u-mt-4 { margin-top: var(--space-4); }
.u-mb-3 { margin-bottom: var(--space-3); }
.u-mb-4 { margin-bottom: var(--space-4); }
.u-mb-6 { margin-bottom: var(--space-6); }
.text-center { text-align: center; }
.w-full { width: 100%; }
code.inline-code {
font-size: 0.75rem;
padding: 0 var(--space-1);
background: var(--muted);
border-radius: var(--radius);
}
.section-title {
font-size: 0.875rem;
font-weight: 600;
margin-bottom: var(--space-4);
}
.section-title:not(:first-child) { margin-top: var(--space-6); }
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--background);
padding: var(--space-6);
}
.login-wrap { max-width: 400px; width: 100%; text-align: center; }
.login-brand-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background: var(--primary);
color: var(--primary-foreground);
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: var(--space-4);
}
.login-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-8);
margin-top: var(--space-6);
text-align: left;
}
.status-good { color: #047857; }
.status-warn { color: #b45309; }
.status-bad { color: #dc2626; }
.status-muted { color: var(--muted-foreground); }
.toolbar-select { width: 120px; }
.toolbar-select--country { width: 160px; }
.toolbar-select--wide { width: 280px; }
.toolbar-input { width: 200px; }
.toolbar-input--wide { width: 260px; }
.toolbar-input--grow { flex: 1; min-width: 200px; }
.table-cell-select { width: 100%; min-width: 200px; }
.el-form .el-select { width: 100%; }
.el-form .el-input-number { width: 100%; }
.app-card .el-table {
--el-table-header-bg-color: var(--muted);
font-size: 0.875rem;
}
.app-card .el-table--small .el-table__cell {
padding: var(--space-2) var(--space-3);
}
.app-card .el-table--small th.el-table__cell {
font-size: 0.75rem;
font-weight: 600;
color: var(--muted-foreground);
}
.app-card .el-table--small td.el-table__cell {
color: var(--el-text-color-regular);
}
.app-card .el-table--small .el-table__row {
height: 40px;
}
.app-card .el-table .el-button.is-link {
padding: 0;
height: auto;
}
.app-card .el-table .cell {
line-height: 1.4;
}
.table-scroll--sm { max-height: 320px; }
.table-scroll--md { max-height: 360px; }
.table-scroll--lg { max-height: 480px; }
.card-toolbar .toolbar-input--grow {
flex: 1;
min-width: 200px;
}
.card-pager .el-pagination {
font-size: 0.875rem;
}
.kv-block p {
margin: 0 0 var(--space-2);
}
.kv-block p:last-child {
margin-bottom: 0;
}
.kv-block .label {
display: inline-block;
width: 5rem;
color: var(--muted-foreground);
}
.kv-block code {
font-size: 0.8125rem;
}
.cell-stack {
display: flex;
flex-direction: column;
gap: var(--space-2);
width: 100%;
}
.meta-list {
margin: 0;
padding: 0;
list-style: none;
font-size: 0.8125rem;
color: var(--muted-foreground);
}
.meta-list li {
display: flex;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-1) 0;
}
.meta-list__mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.meta-list__ok { color: var(--el-color-success); }
.meta-list__warn { color: var(--el-color-warning); }
.chart-box {
height: 280px;
}
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: #e4e4e7 transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #e4e4e7;
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.settings-form {
max-width: 640px;
}
.card-actions {
margin-bottom: var(--space-3);
}
.hidden-input {
display: none;
}
.el-card {
border: 1px solid var(--border);
box-shadow: none !important;
}
.el-dialog {
border-radius: var(--radius);
}
@media (max-width: 1024px) {
.stat-grid { grid-template-columns: repeat(2, 1fr); }
.content-grid--2-1,
.content-grid--2,
.content-grid--3 { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
.stat-grid { grid-template-columns: 1fr; }
.main-content { padding: var(--space-4); }
.page-header { flex-direction: column; }
}
+41
View File
@@ -0,0 +1,41 @@
/** §6.16 ECharts 品牌色与默认 grid */
export const CHART_COLORS = ['#0f7a62', '#71717a', '#4da08c', '#76b8a8', '#a0d0c4']
const AXIS_LABEL = { color: '#71717a', fontSize: 12 }
const AXIS_LINE = { lineStyle: { color: '#e4e4e7' } }
const SPLIT_LINE = { lineStyle: { color: '#e4e4e7' } }
export function buildLineChartOption({ labels, series, legend = true, yAxisFormatter }) {
return {
color: CHART_COLORS,
grid: { left: 40, right: 16, top: legend ? 36 : 16, bottom: 24 },
legend: legend
? { top: 0, textStyle: { fontSize: 12, color: '#71717a' } }
: undefined,
tooltip: { trigger: 'axis' },
xAxis: {
type: 'category',
data: labels,
axisLabel: AXIS_LABEL,
axisLine: AXIS_LINE,
},
yAxis: {
type: 'value',
axisLabel: {
...AXIS_LABEL,
formatter: yAxisFormatter,
},
axisLine: AXIS_LINE,
splitLine: SPLIT_LINE,
},
series: series.map((item, index) => ({
name: item.name,
type: 'line',
smooth: true,
data: item.data,
color: CHART_COLORS[index % CHART_COLORS.length],
lineStyle: { width: 2 },
showSymbol: false,
})),
}
}
+22
View File
@@ -0,0 +1,22 @@
/** §7.7: YYYY-MM-DD HH:mm:ss */
export function formatDateTime(value) {
if (!value) return '-'
let iso
if (value instanceof Date) {
if (Number.isNaN(value.getTime())) return '-'
iso = value.toISOString()
} else {
const d = new Date(value)
if (Number.isNaN(d.getTime())) return String(value)
iso = d.toISOString()
}
return iso.replace('T', ' ').slice(0, 19)
}
/** 图表 X 轴短标签 */
export function formatChartTime(date = new Date()) {
const h = String(date.getHours()).padStart(2, '0')
const m = String(date.getMinutes()).padStart(2, '0')
const s = String(date.getSeconds()).padStart(2, '0')
return `${h}:${m}:${s}`
}
+11
View File
@@ -0,0 +1,11 @@
export function formatBytes(n) {
if (!n) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let i = 0
let val = n
while (val >= 1024 && i < units.length - 1) {
val /= 1024
i++
}
return `${val.toFixed(1)} ${units[i]}`
}
+52
View File
@@ -0,0 +1,52 @@
import api from '@/api'
export function downloadJSON(filename, data) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
export function parseImportFile(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
try {
const data = JSON.parse(reader.result)
const items = Array.isArray(data) ? data : data?.items
if (!Array.isArray(items) || items.length === 0) {
reject(new Error('文件中没有可导入的配置项'))
return
}
resolve(items)
} catch {
reject(new Error('JSON 格式无效'))
}
}
reader.onerror = () => reject(new Error('读取文件失败'))
reader.readAsText(file)
})
}
export async function exportResource(path, filename) {
const { data } = await api.get(`${path}/export`)
downloadJSON(filename, data)
return data
}
export async function importResource(path, mode, items) {
const { data } = await api.post(`${path}/import`, { mode, items })
return data
}
export function importModeLabel(mode) {
const map = {
upsert: '覆盖更新(已存在则更新)',
skip: '跳过已存在(仅导入新配置)',
create: '全部新建(冲突则跳过)',
}
return map[mode] || mode
}

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