feat: Wormhole 内网穿透与反向代理网关
Go + Vue 管理台,SQLite 持久化;支持隧道/域名穿透、域名转发插件链、访客认证、 IP 规则、API Key、Docker 单镜像部署;配置通过 Web 系统设置管理,无需 YAML 文件。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
nps/
|
||||
.git/
|
||||
bin/
|
||||
release/
|
||||
data/
|
||||
**/.DS_Store
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
agent-transcripts/
|
||||
*.md
|
||||
!README.md
|
||||
+32
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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 重建 Agent;Agent 需 `--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`
|
||||
@@ -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
|
||||
|
||||
`)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# 项目级 Go 工具链环境(go mod / go build 会读取)
|
||||
GOPROXY=https://goproxy.cn,direct
|
||||
GOSUMDB=sum.golang.google.cn
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
@@ -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": "缓存容量 MB,0 表示禁用", "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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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()})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Vendored
+605
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, "; ")
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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-For(Nginx 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package version
|
||||
|
||||
const Version = "0.1.0"
|
||||
@@ -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
|
||||
);
|
||||
@@ -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 '';
|
||||
@@ -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
|
||||
);
|
||||
Executable
+37
@@ -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]"
|
||||
Executable
+32
@@ -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})"
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
exec /usr/local/bin/wormhole "$@"
|
||||
@@ -0,0 +1 @@
|
||||
registry=https://registry.npmmirror.com
|
||||
Vendored
+172
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+13
@@ -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
@@ -0,0 +1,6 @@
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed dist/*
|
||||
var Dist embed.FS
|
||||
@@ -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>
|
||||
Generated
+1846
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
@@ -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: 'MB(0 表示禁用缓存)',
|
||||
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: '已加入黑名单',
|
||||
},
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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]}`
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user