feat: Wormhole 内网穿透与反向代理网关
CI / docker (push) Successful in 1m36s

Go + Vue 管理台:Agent 隧道、域名反代、IP 安全、访客验证与监控大盘。
Gitea Actions CI 多架构镜像;纯 Go SQLite、无 CGO Docker 构建。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-15 04:46:59 +00:00
committed by renjue
commit 9a5de83fee
111 changed files with 15770 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
.git/
bin/
release/
data/
**/.DS_Store
web/node_modules/
web/dist/
.gitea/
*.md
!README.md
+182
View File
@@ -0,0 +1,182 @@
# Gitea Actions CI 模板(Go + Vue
复制整个 `.gitea/` 目录到新仓库即可启用 CI:**可选 go test** + **Docker 构建/推送**Go 编译与 Vue 构建在 Dockerfile 内完成)。
## 项目约定
| 路径 | 说明 |
|------|------|
| `go.mod` | 仓库根目录 |
| `Dockerfile` | 仓库根目录,多阶段构建(含前端 `web/` |
| `web/` | 可选;由 Dockerfile 内 `npm run build` 处理,CI 不再单独构建 |
## Dockerfile 要求
工作流的 **Build image** 步骤在仓库根目录(或 Variable `DOCKERFILE` 指定路径)执行 `docker buildx build`**不会**在 CI 里单独装 Node / 跑 `npm`。因此 Dockerfile 必须能独立完成构建与(运行时)启动。
### 基本要求
| 项 | 要求 |
|----|------|
| 位置 | 默认仓库根目录 `Dockerfile`;其他路径用 Variable `DOCKERFILE` |
| 构建上下文 | 仓库根目录 `.`(整个仓库会被 `COPY` 进镜像) |
| Go 版本 | `go.mod``go` 行与 `golang` 基础镜像大版本一致 |
| 多架构 | `go build` 须使用 `GOARCH="$TARGETARCH"`buildx 注入);推荐 `CGO_ENABLED=0` |
| Go 模块代理 | **必须**在镜像内显式设置 `GOPROXY` / `GOSUMDB`(见下);容器内不会自动读取 `go.env` |
| 基础镜像加速 | 支持构建参数 `IMAGE_PREFIX`CI 默认 `docker.1panel.live/library/` |
| 前端(可选) | 存在 `web/` 时在 Dockerfile 内完成 `npm ci` + `npm run build` |
### CI 自动传入的构建参数
| 参数 | 默认 | 说明 |
|------|------|------|
| `IMAGE_PREFIX` | `docker.1panel.live/library/` | 基础镜像前缀;`hub` 表示 Docker Hub |
| `GOPROXY` | `https://goproxy.cn,direct` | 与 workflow / Variable 一致 |
| `GOSUMDB` | `sum.golang.google.cn` | checksum 数据库 |
buildx 还会注入 `TARGETARCH``BUILDPLATFORM` 等,无需在 workflow 里写。
### 推荐:Go + Vue 多阶段模板
```dockerfile
# syntax=docker/dockerfile:1
ARG IMAGE_PREFIX=
# 1) 前端(无 web/ 时可删整个 stage,并去掉 go-builder 里 COPY dist
FROM --platform=$BUILDPLATFORM ${IMAGE_PREFIX}node:20-alpine AS web-builder
WORKDIR /src/web
COPY web/package.json web/package-lock.json web/.npmrc ./
RUN npm ci
COPY web/ ./
RUN npm run build
# 2) Go 编译
FROM --platform=$BUILDPLATFORM ${IMAGE_PREFIX}golang:1.25-alpine AS go-builder
ARG TARGETARCH
ARG GOPROXY=https://goproxy.cn,direct
ARG GOSUMDB=sum.golang.google.cn
ENV GOPROXY=${GOPROXY} GOSUMDB=${GOSUMDB}
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY cmd/ ./cmd/
COPY internal/ ./internal/
# 若静态资源嵌入 GoCOPY --from=web-builder /src/web/dist/ ./path/to/static/
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux GOARCH="$TARGETARCH" \
go build -ldflags="-w -s" -o /app ./cmd/yourapp
# 3) 运行镜像
FROM ${IMAGE_PREFIX}alpine:3.20
RUN apk add --no-cache ca-certificates tzdata
COPY --from=go-builder /app /usr/local/bin/yourapp
ENTRYPOINT ["/usr/local/bin/yourapp"]
```
按项目调整:`./cmd/yourapp`、静态资源路径、运行用户、`EXPOSE` / `VOLUME` 等。
### 仅 Go(无前端)
删除 `web-builder` stage`go-builder` 中不要 `COPY` 前端产物;其余 `GOPROXY` / `TARGETARCH` / `IMAGE_PREFIX` 要求相同。
### 前端 npm 源(国内)
`web/.npmrc` 配置 registry,并在 Dockerfile 里与 `package.json` 一并 `COPY`
```ini
registry=https://registry.npmmirror.com
```
### 本地验证(与 CI 一致)
```bash
docker buildx build --platform linux/amd64 \
--build-arg IMAGE_PREFIX=docker.1panel.live/library/ \
--build-arg GOPROXY=https://goproxy.cn,direct \
-f Dockerfile -t myapp:test .
```
### 常见 Dockerfile 构建错误
| 报错 | 原因 | 处理 |
|------|------|------|
| `proxy.golang.org` timeout | 镜像内未设 `ENV GOPROXY` | go-builder 阶段加 `ARG`/`ENV GOPROXY` |
| `node` / Vite 版本不符 | 基础镜像 Node 过旧 | 使用 `node:20-alpine` 及以上 |
| 某架构 build 失败 | 未使用 `TARGETARCH` | `GOARCH="$TARGETARCH"` |
| 拉基础镜像 timeout | Hub 不可达 | `--build-arg IMAGE_PREFIX=docker.1panel.live/library/` 或 1Panel 配镜像加速 |
## 一次性配置
1. **Runner**:部署 act_runner,标签含 `ubuntu-latest`,并挂载 `docker.sock`(见 [act-runner/README.md](act-runner/README.md))。
2. **Secret**:仓库 Settings → Actions → Secrets,添加 `REGISTRY_TOKEN`PAT`write:package` 权限)。
3. **Variable(推荐)**:若 runner 与 Gitea 同机、或 `gitea.server_url` 为内网地址,必须设置 `REGISTRY=你的公网域名`(如 `git.example.com`**不要**填 `172.17.0.1:13827`)。
4. **Gitea Registry**:服务端 `[packages] ENABLED = true``ROOT_URL` 正确;穿透场景建议 `PUBLIC_URL_DETECTION = never`Gitea 1.26+)。
`REGISTRY` 未设置时,会从 `GITEA_ROOT_URL``gitea.server_url` 推断;均为内网地址时 workflow 会提前失败并提示。
## 可选 Variables
仓库 Settings → Actions → Variables(留空则用默认值):
| 变量 | 默认 | 说明 |
|------|------|------|
| `REGISTRY` | 见下方推断顺序 | **公网** Registry 主机名,如 `git.example.com`(勿用内网 IP:13827 |
| `GITEA_ROOT_URL` | (空) | 当 `gitea.server_url` 为内网时,可设 `https://git.example.com/` |
| `IMAGE_NAME` | 仓库名小写 | 镜像名,非 owner/repo 全路径 |
| `DOCKERFILE` | `Dockerfile` | Dockerfile 路径 |
| `DOCKER_PLATFORMS` | `linux/amd64,linux/arm64` | push 时 buildx 平台 |
| `GO_TEST_SCOPE` | `./...` | `go test` 包路径 |
| `RUN_GO_TEST` | (空,即运行) | 设 `false` 跳过 go test,仅 Docker 构建 |
| `DOCKER_IMAGE_PREFIX` | `1panel` | 基础镜像前缀;可选 `hub` / `daocloud` |
| `GOPROXY` | `https://goproxy.cn,direct` | Go 模块代理 |
| `GOSUMDB` | `sum.golang.google.cn` | Go checksum 数据库 |
## 触发与镜像 tag
- **pull_request**go test(可关)+ 单架构 `docker build` 验证(不推送)
- **push main/master**go test + 多架构构建并推送 `:latest``:sha-xxxxxxx`
- **push tag v\***:额外推送 `:v1.2.3`
示例(仓库 `rose_cat707/Prism`Gitea 在 `git.example.com`):
```text
git.example.com/rose_cat707/prism:latest
git.example.com/rose_cat707/prism:sha-35b3b48
```
## 目录结构
```text
.gitea/
├── README.md # 本文件
├── workflows/
│ └── ci.yml # 主工作流
└── act-runner/ # runner 部署参考(可选)
├── README.md
├── config.yaml
├── docker-compose.yml
└── .env.example
```
## 本地验证 Registry
```bash
host=$(echo "https://你的-gitea-地址/" | sed -e 's|^https://||' -e 's|/.*||')
curl -s -D - "https://${host}/v2/" -o /dev/null | grep -i www-authenticate
docker pull "${host}/owner/image:latest" # 公开 Registry 无需 login
```
推送镜像(CI)仍需仓库 Secret `REGISTRY_TOKEN``write:package`)。仅拉取公开包不需要登录。
`realm` 应指向公网 Gitea 域名,而非 `127.0.0.1`
## 常见 Registry 错误
| 报错 | 原因 | 处理 |
|------|------|------|
| `Get "https://172.17.0.1:13827/v2/"` HTTP/HTTPS | Variable `REGISTRY``gitea.server_url` 为内网地址 | 设 `REGISTRY=git.example.com` |
| token 指向 `127.0.0.1` | Gitea `realm` 配置错误 | `PUBLIC_URL_DETECTION=never` + 正确 `ROOT_URL` |
+3
View File
@@ -0,0 +1,3 @@
GITEA_INSTANCE_URL=https://git.example.com
GITEA_RUNNER_REGISTRATION_TOKEN=从仓库_Settings_Actions_Runners_复制
GITEA_RUNNER_NAME=go-vue-ci-runner
+177
View File
@@ -0,0 +1,177 @@
# act_runner 配置参考
工作流 `runs-on: ubuntu-latest``docker` job 额外使用 `catthehacker/ubuntu:act-22.04` 容器(含 Docker CLI)。
## 构建镜像必须:挂载 Docker Socket
`docker` job 需要访问宿主机 Docker 引擎。在 **runner 所在机器**`config.yaml` 中配置:
```yaml
container:
options: -v /var/run/docker.sock:/var/run/docker.sock
valid_volumes:
- /var/run/docker.sock
```
推荐 job 镜像(含 Node + Docker CLI):
```yaml
runner:
labels:
- "ubuntu-latest:docker://catthehacker/ubuntu:act-22.04"
- "ubuntu-22.04:docker://catthehacker/ubuntu:act-22.04"
```
修改后 **重启 runner**(例如 `docker restart <runner容器>` 或重启 Gitea Runner 服务)。
## 1Panel 宿主机:配置 Docker 镜像加速(推荐)
Runner 通过 `docker.sock` 使用宿主机 Docker。在 **1Panel** 中配置加速器后,普通 `docker pull` 会走加速;**buildx 多架构构建**仍建议配合 workflow 内的 `IMAGE_PREFIX`(默认 `docker.1panel.live`)。
1. 登录 1Panel → **容器****配置**
2. **镜像加速地址** 填入:
```text
https://docker.1panel.live
```
3. 保存并 **重启 Docker**
验证(在 runner 宿主机):
```bash
docker info | grep -A5 'Registry Mirrors'
docker pull alpine:3.20
```
等价 `daemon.json`
```json
{
"registry-mirrors": ["https://docker.1panel.live"]
}
```
CI 工作流默认 `IMAGE_PREFIX=docker.1panel.live/library/`buildkit 拉基础镜像)。若 1Panel 加速不可用,可在仓库 Variables 设 `DOCKER_IMAGE_PREFIX=daocloud` 或 `hub`。
参考:[1Panel 容器配置文档](https://1panel.cn/docs/user_manual/containers/setting)
## 验证
在 runner 宿主机执行:
```bash
docker info
ls -l /var/run/docker.sock
```
## 从零部署 runner(可选)
```bash
cd .gitea/act-runner
cp .env.example .env # 填入 Registration Token
docker compose up -d
```
## GitHub Actions 镜像(替代 ghfast
日志里出现 `git clone 'https://ghfast.top/https://github.com/actions/checkout'` 说明 **runner 宿主机** 的 `config.yaml` 配置了 `github_mirror`,与仓库 workflow 无关。
在 runner 的 `config.yaml` 中修改(修改后重启 runner):
```yaml
runner:
github_mirror: 'https://gitea.com' # 推荐
```
常用替代方案:
| `github_mirror` 值 | 说明 |
|------------------|------|
| `''`(留空) | 直连 `github.com`,网络可达时最简单 |
| `https://gitea.com` | Gitea 官方 actions 镜像,国内较稳 |
| `https://gitclone.com/github.com` | 第三方 GitHub 克隆镜像 |
| `https://ghfast.top/https://github.com` | 部分环境需代理认证,易报 `Proxy Authentication Required` |
前提:Gitea `app.ini` 中 `[actions] DEFAULT_ACTIONS_URL = github`(默认)。
**不依赖 runner 镜像** 的写法(workflow 内写绝对 URL,仅改写 `github.com` 的请求):
```yaml
uses: https://gitea.com/actions/checkout@v4
```
本模板 `ci.yml` 已采用此写法。若其他 workflow 仍写 `uses: actions/checkout@v4`,仍需配置 `github_mirror` 或改为绝对 URL。
修改 `github_mirror` 后建议清理 runner 缓存目录(如 `/root/.cache/act`),否则旧缓存可能仍指向 ghfast。
## 常见错误
| 报错 | 原因 | 处理 |
|------|------|------|
| `registry-1.docker.io` i/o timeout | Docker Hub 不可达 | 1Panel 配 `docker.1panel.live`Variable `DOCKER_IMAGE_PREFIX=1panel` |
| `ghfast.top` Proxy Authentication Required | runner `github_mirror` 指向 ghfast 且需代理认证 | 改 `github_mirror` 或删掉;见上文「GitHub Actions 镜像」 |
| `docker: command not found` | job 容器无 Docker CLI | 工作流已指定 act 镜像;或 runner 改用 catthehacker/ubuntu |
| `Cannot connect to Docker daemon` | 未挂载 docker.sock | 按上文修改 config.yaml 并重启 runner |
| `node not in PATH` | job 镜像无 Node | 标签映射改用 catthehacker/ubuntu:act-22.04 |
| `http: server gave HTTP response to HTTPS client` 且 token 指向 `127.0.0.1` | **Gitea Registry 配置/反代错误** | 见下文「Registry 登录失败」 |
## Registry 登录失败(127.0.0.1 / HTTP vs HTTPS
若 `docker login git.rc707blog.top` 报错类似:
```text
Get "https://127.0.0.1:xxxxx/v2/token?...": http: server gave HTTP response to HTTPS client
```
说明 Gitea 把 **Docker 认证 token 地址** 配成了本机内网地址,CI runner 访问不到。需在 **Gitea 服务器** 修复,而非改 workflow。
### 1. 检查 `app.ini`
```ini
[server]
ROOT_URL = https://git.example.com/
LOCAL_ROOT_URL = http://127.0.0.1:3000/
; 内网穿透 / 错误 Host 时(Gitea 1.26+):
; PUBLIC_URL_DETECTION = never
[packages]
ENABLED = true
```
`ROOT_URL` 必须与浏览器访问 Gitea 的 **HTTPS 外网地址** 完全一致(含末尾 `/`)。
### 2. 反向代理必须转发 `/v2` 并带上头
Container Registry 固定使用根路径 `/v2`。Nginx 示例:
```nginx
location / {
client_max_body_size 0;
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
关键:`X-Forwarded-Proto: https` 和正确的 `Host`(与 Gitea `ROOT_URL` 域名一致)。
### 3. 在 runner 宿主机验证(修复后)
```bash
GITEA_HOST=git.example.com # 改成你的 Gitea 域名
curl -s -D - "https://${GITEA_HOST}/v2/" -o /dev/null | grep -i www-authenticate
echo "$REGISTRY_TOKEN" | docker login "${GITEA_HOST}" -u 你的用户名 --password-stdin
```
应返回 `401 Unauthorized`(正常,表示 registry 可达)且 `docker login` 显示 **Login Succeeded**。
参考:[Gitea 反向代理文档](https://docs.gitea.com/administration/reverse-proxies)
## Gitea Runner v0.6.x(个人 runner
1. 找到 runner 的配置文件或环境(安装目录 / docker compose
2. 确保 runner 进程能访问宿主机 `/var/run/docker.sock`
3. Runners 页标签含 `ubuntu-latest` 且状态 **空闲/在线**
若使用 Gitea 网页注册的个人 runnerdocker 模式),通常需在 runner 启动参数或 `config.yaml` 里加入 socket 挂载,具体路径取决于你的安装方式。
+34
View File
@@ -0,0 +1,34 @@
# act_runner 配置(可选参考部署)
# 工作流 runs-on: ubuntu-latest
log:
level: info
runner:
file: .runner
capacity: 2
timeout: 3h
insecure: false
fetch_timeout: 5s
fetch_interval: 2s
# 拉取 uses: actions/checkout@v4 等 GitHub Action 时的镜像(替换 https://github.com
# 需 Gitea app.ini 中 [actions] DEFAULT_ACTIONS_URL = github
# 留空则直连 github.com;第三方镜像不稳定时可改用 workflow 绝对 URL(见 .gitea/README.md
#
# github_mirror: '' # 直连 GitHub
# github_mirror: 'https://gitea.com' # 推荐:Gitea 官方 actions 镜像
# github_mirror: 'https://gitclone.com/github.com' # 第三方 GitHub 克隆镜像
# github_mirror: 'https://ghfast.top/https://github.com' # 需代理认证时易失败,不推荐
labels:
- "ubuntu-latest:docker://catthehacker/ubuntu:act-22.04"
- "ubuntu-22.04:docker://catthehacker/ubuntu:act-22.04"
cache:
enabled: false
container:
network: bridge
privileged: false
options: -v /var/run/docker.sock:/var/run/docker.sock
valid_volumes:
- /var/run/docker.sock
+22
View File
@@ -0,0 +1,22 @@
# Gitea act_runner — 可选参考部署(标签 default,与工作流一致)
#
# 若已在 Gitea 注册个人/仓库 runner 且标签为 default,无需使用本目录。
services:
act-runner:
image: docker.io/gitea/act_runner:0.2.12
container_name: prism-act-runner
restart: unless-stopped
environment:
CONFIG_FILE: /config.yaml
GITEA_INSTANCE_URL: ${GITEA_INSTANCE_URL:-https://git.rc707blog.top}
GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN}
GITEA_RUNNER_NAME: ${GITEA_RUNNER_NAME:-prism-ci-runner}
volumes:
- ./config.yaml:/config.yaml:ro
- act-runner-data:/data
- /var/run/docker.sock:/var/run/docker.sock
working_dir: /data
volumes:
act-runner-data:
+236
View File
@@ -0,0 +1,236 @@
# 通用 Go + Vue 项目 CIGitea Actions
#
# 单 job:可选 go test + Docker 多架构构建/推送
# 约定:go.mod、Dockerfile(详见 .gitea/README.md「Dockerfile 要求」);前端(可选)在 web/
#
# 前置:act_runnerubuntu-latest + docker.sock)、Secret REGISTRY_TOKEN
# VariablesREGISTRY、IMAGE_NAME、DOCKER_IMAGE_PREFIX、RUN_GO_TEST 等
name: CI
on:
push:
branches: [main, master]
tags: ['v*']
pull_request:
env:
REGISTRY: ${{ vars.REGISTRY }}
GITEA_ROOT_URL: ${{ vars.GITEA_ROOT_URL }}
IMAGE_NAME: ${{ vars.IMAGE_NAME }}
DOCKERFILE: ${{ vars.DOCKERFILE }}
DOCKER_PLATFORMS: ${{ vars.DOCKER_PLATFORMS }}
GO_TEST_SCOPE: ${{ vars.GO_TEST_SCOPE }}
RUN_GO_TEST: ${{ vars.RUN_GO_TEST }}
DOCKER_IMAGE_PREFIX: ${{ vars.DOCKER_IMAGE_PREFIX }}
GOPROXY: ${{ vars.GOPROXY }}
GOSUMDB: ${{ vars.GOSUMDB }}
jobs:
docker:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run Go tests
if: vars.RUN_GO_TEST != 'false'
shell: bash
run: |
set -euo pipefail
export GOPROXY="${GOPROXY:-https://goproxy.cn,direct}"
export GOSUMDB="${GOSUMDB:-sum.golang.google.cn}"
GO_VERSION=$(grep '^go ' go.mod | awk '{print $2}')
ARCH=$(uname -m | sed 's/x86_64/amd64/; s/aarch64/arm64/')
TAR="go${GO_VERSION}.linux-${ARCH}.tar.gz"
if ! command -v go >/dev/null 2>&1 || ! go version | grep -q "go${GO_VERSION} "; then
downloaded=0
for url in \
"https://mirrors.aliyun.com/golang/${TAR}" \
"https://golang.google.cn/dl/${TAR}" \
"https://go.dev/dl/${TAR}"; do
echo "trying ${url}"
if curl -fsSL --connect-timeout 20 --retry 3 --retry-delay 5 --max-time 600 \
"$url" -o /tmp/go.tgz; then
downloaded=1
break
fi
done
[ "$downloaded" -eq 1 ] || { echo "failed to download Go ${GO_VERSION}" >&2; exit 1; }
rm -rf /usr/local/go
tar -C /usr/local -xzf /tmp/go.tgz
export PATH="/usr/local/go/bin:${PATH}"
fi
go version
go test "${GO_TEST_SCOPE:-./...}"
- name: Setup Docker
run: |
set -eux
if ! command -v docker >/dev/null 2>&1; then
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y docker.io
fi
if [ ! -S /var/run/docker.sock ]; then
echo "ERROR: /var/run/docker.sock 未挂载到 job 容器。" >&2
echo "请在 act_runner config.yaml 中配置:" >&2
echo " container.options: -v /var/run/docker.sock:/var/run/docker.sock" >&2
echo " container.valid_volumes: [/var/run/docker.sock]" >&2
exit 1
fi
docker info
- name: Resolve registry
id: reg
if: gitea.event_name == 'push'
shell: bash
env:
GITEA_SERVER_URL: ${{ gitea.server_url }}
run: |
set -euo pipefail
url_to_host() {
echo "$1" | sed -e 's|^https://||' -e 's|^http://||' -e 's|/.*||'
}
is_internal_host() {
local host="${1%%:*}"
case "$host" in
localhost|127.*|10.*|192.168.*) return 0 ;;
172.*)
local second
second=$(echo "$host" | cut -d. -f2)
[ "$second" -ge 16 ] && [ "$second" -le 31 ]
return
;;
*) return 1 ;;
esac
}
registry="${REGISTRY:-}"
if [ -z "$registry" ] && [ -n "${GITEA_ROOT_URL:-}" ]; then
registry=$(url_to_host "${GITEA_ROOT_URL}")
fi
if [ -z "$registry" ]; then
registry=$(url_to_host "${GITEA_SERVER_URL}")
fi
if is_internal_host "$registry"; then
echo "ERROR: Registry 主机 '${registry}' 是内网地址。" >&2
echo "请设置 Variable REGISTRY=公网 Gitea 域名(如 git.example.com)。" >&2
exit 1
fi
echo "registry host: ${registry}"
auth_header=""
if auth_header=$(curl -fsS -D - "https://${registry}/v2/" -o /dev/null 2>&1 | grep -i '^www-authenticate:'); then
if echo "$auth_header" | grep -qiE '127\.0\.0\.1|172\.(1[6-9]|2[0-9]|3[01])\.|localhost|:13827'; then
echo "ERROR: Registry token realm 指向内网地址:${auth_header}" >&2
exit 1
fi
fi
echo "host=${registry}" >> "${GITHUB_OUTPUT}"
- name: Log in to Container Registry
if: gitea.event_name == 'push'
shell: bash
run: |
set -euo pipefail
echo "${{ secrets.REGISTRY_TOKEN }}" | \
docker login "${{ steps.reg.outputs.host }}" -u "${{ gitea.actor }}" --password-stdin
- name: Build image
shell: bash
env:
GITEA_SHA: ${{ gitea.sha }}
GITEA_REF: ${{ gitea.ref }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_REPOSITORY_OWNER: ${{ gitea.repository_owner }}
GITEA_EVENT_NAME: ${{ gitea.event_name }}
REGISTRY_HOST: ${{ steps.reg.outputs.host }}
run: |
set -euo pipefail
dockerfile="${DOCKERFILE:-Dockerfile}"
if [ "${DOCKER_IMAGE_PREFIX:-}" = "hub" ] || [ "${DOCKER_IMAGE_PREFIX:-}" = "docker.io" ]; then
image_prefix=""
elif [ -z "${DOCKER_IMAGE_PREFIX:-}" ] || [ "${DOCKER_IMAGE_PREFIX}" = "1panel" ]; then
image_prefix="docker.1panel.live/library/"
elif [ "${DOCKER_IMAGE_PREFIX}" = "daocloud" ]; then
image_prefix="docker.m.daocloud.io/library/"
else
image_prefix="${DOCKER_IMAGE_PREFIX}"
[[ "${image_prefix}" == */ ]] || image_prefix="${image_prefix}/"
fi
echo "using IMAGE_PREFIX=${image_prefix:-<docker.io>}"
build_args=(
--build-arg "IMAGE_PREFIX=${image_prefix}"
--build-arg "GOPROXY=${GOPROXY:-https://goproxy.cn,direct}"
--build-arg "GOSUMDB=${GOSUMDB:-sum.golang.google.cn}"
)
builder_id="gitea-buildx-${GITEA_REPOSITORY//\//-}"
if [ "${GITEA_EVENT_NAME}" = "push" ]; then
install_binfmt() {
for img in \
"docker.1panel.live/tonistiigi/binfmt:latest" \
"docker.m.daocloud.io/tonistiigi/binfmt:latest" \
"tonistiigi/binfmt:latest"; do
echo "trying binfmt image: ${img}"
if docker run --privileged --rm "${img}" --install all; then
return 0
fi
done
return 1
}
install_binfmt || true
registry="${REGISTRY_HOST}"
image_name="${IMAGE_NAME:-$(echo "${GITEA_REPOSITORY##*/}" | tr '[:upper:]' '[:lower:]')}"
platforms="${DOCKER_PLATFORMS:-linux/amd64,linux/arm64}"
image="${registry}/${GITEA_REPOSITORY_OWNER}/${image_name}"
tags="${image}:sha-${GITEA_SHA:0:7}"
case "${GITEA_REF}" in
refs/heads/main|refs/heads/master)
tags="${tags},${image}:latest"
;;
refs/tags/v*)
tags="${tags},${image}:${GITEA_REF#refs/tags/}"
;;
esac
docker buildx create --name "${builder_id}" --use 2>/dev/null || docker buildx use "${builder_id}"
docker buildx inspect --bootstrap
tag_args=()
IFS=',' read -r -a tag_list <<< "$tags"
for t in "${tag_list[@]}"; do
tag_args+=(-t "$t")
done
docker buildx build \
--platform "${platforms}" \
-f "${dockerfile}" \
"${build_args[@]}" \
"${tag_args[@]}" \
--push \
.
else
# PR:仅单架构构建验证 Dockerfile,不推送
docker buildx create --name "${builder_id}" --use 2>/dev/null || docker buildx use "${builder_id}"
docker buildx inspect --bootstrap
docker buildx build \
--platform linux/amd64 \
-f "${dockerfile}" \
"${build_args[@]}" \
--load \
.
fi
+49
View File
@@ -0,0 +1,49 @@
# Binaries
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
# Go
go.work
go.work.sum
bin/
coverage.out
coverage.html
*.coverprofile
# Dependencies
vendor/
# Environment & secrets
.env
.env.*
!.env.example
# Runtime data
data/
release/
# Frontend build artifacts
web/node_modules/
web/dist/*
!web/dist/
!web/dist/index.html
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Temp
tmp/
dist/
+74
View File
@@ -0,0 +1,74 @@
# Wormhole 内网穿透 + 反向代理网关(Go + Vue 管理台)
#
# 构建:
# 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 --restart=unless-stopped --network host \
# wormhole:latest agent -server <服务端IP>:8528 -key <verify_key>
#
# 生产镜像由 Gitea Actions 构建,见 README
# syntax=docker/dockerfile:1
# 基础镜像默认 Docker HubCI 默认 1Panel 加速(docker.1panel.live),本地可传:
# docker build --build-arg IMAGE_PREFIX=docker.1panel.live/library/ -t wormhole:latest .
ARG IMAGE_PREFIX=
FROM --platform=$BUILDPLATFORM ${IMAGE_PREFIX}node:20-alpine AS web-builder
WORKDIR /src/web
COPY web/package.json web/package-lock.json web/.npmrc ./
RUN npm ci
COPY web/ ./
RUN npm run build
FROM --platform=$BUILDPLATFORM ${IMAGE_PREFIX}golang:1.25-alpine AS go-builder
ARG TARGETARCH
ARG GOPROXY=https://goproxy.cn,direct
ARG GOSUMDB=sum.golang.google.cn
ENV GOPROXY=${GOPROXY} GOSUMDB=${GOSUMDB}
WORKDIR /src
COPY go.mod go.sum go.env ./
RUN --mount=type=cache,target=/go/pkg/mod \
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 \
CGO_ENABLED=0 GOOS=linux GOARCH="$TARGETARCH" \
go build -ldflags="-w -s" -o /wormhole ./cmd/wormhole
FROM ${IMAGE_PREFIX}alpine:3.20
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"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Wormhole Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+30
View File
@@ -0,0 +1,30 @@
# 运行参考(配置已迁移至 Web 管理台 → 系统设置,无需配置文件)
.PHONY: tidy build build-all dev-web web install dev run run-agent
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
+254
View File
@@ -0,0 +1,254 @@
# Wormhole
内网穿透 + 反向代理网关,带 Web 管理台。控制面自研(Go + SQLite),数据面支持 HTTP/HTTPS 域名反代、TCP/UDP 隧道与 Agent 长连接。
服务端与 Agent 为**同一应用**,配置均在 Web 管理台完成,无需手写配置文件。管理台支持**简体中文、英文**两种界面语言,可在顶栏随时切换。
## 界面预览
以下为 Web 管理台主要页面(点击可查看原图)。
| 登录 | 监控大盘 |
|:---:|:---:|
| ![登录页](docs/image/登陆页.png) | ![监控大盘](docs/image/概览-仪表盘.png) |
| 默认密码 `admin`,首次登录后请修改 | 流量统计、请求 IP 与资源概览 |
| 客户端 | 隧道 |
|:---:|:---:|
| ![客户端](docs/image/穿透-客户端.png) | ![隧道](docs/image/穿透-隧道.png) |
| Agent 注册、在线状态与 verify_key | TCP/UDP 端口映射、启停与导入导出 |
| 域名反代 | 访客验证 |
|:---:|:---:|
| ![域名反代](docs/image/穿透-域名.png) | ![访客验证](docs/image/访客验证.png) |
| HTTP/HTTPS Host 路由、TLS 与请求头 | Basic / 表单 / 回调,资源级访问控制 |
| IP 规则 | 请求 IP |
|:---:|:---:|
| ![IP 规则](docs/image/安全-IP规则.png) | ![请求 IP](docs/image/安全-请求 IP.png) |
| 精确 / CIDR / 正则白黑名单 | 访客 IP 监控、一键拉黑 |
| 访客用户 | 系统设置 |
|:---:|:---:|
| ![访客用户](docs/image/系统-访客用户.png) | ![系统设置](docs/image/系统-系统设置.png) |
| 受保护资源的访客账号管理 | 端口、认证、JWT、导入导出 |
## 功能概览
| 模块 | 说明 |
|------|------|
| 客户端 | Agent 注册、在线状态、verify_key 管理 |
| 隧道 | TCP/UDP 端口映射,支持访客登录与导入导出 |
| 域名反代 | HTTP/HTTPS Host 路由、TLS 证书、代理请求头 |
| 访客验证 | Basic / 表单 / 回调认证,按资源授权访客 |
| IP 安全 | 精确 / CIDR / 正则白黑名单,请求 IP 监控与一键拉黑 |
| 监控大盘 | 流量统计、Top 请求 IP、资源与隧道概览 |
| 系统设置 | 监听端口、JWT、登录限流、数据导入导出 |
| OpenAPI | 仪表盘可打开 `/api/openapi.json` |
## 架构
```
公网用户 → HTTP/HTTPS 反代 (8081/8443) ──→ 后端服务 / Agent 隧道
Agent Bridge (8528) ←── 内网 Agentsmux 长连接)
Wormhole 控制面 (REST API + Vue UI :8529)
SQLite + 内存缓存
```
## 快速开始(开发)
### 依赖
- Go 1.24+(见 `go.mod`
- Node.js 20+(仅开发/构建前端)
依赖镜像(已写入 `go.env``web/.npmrc`,国内网络友好):
- Go`GOPROXY=https://goproxy.cn,direct`
- npm`registry=https://registry.npmmirror.com`
海外用户可将 `GOPROXY` 改为 `https://proxy.golang.org,direct`npm 使用默认 registry 即可。
### 启动
```bash
make install # 安装前端依赖
# 终端 1:后端
make run
# 终端 2:前端(Vite 代理 /api → :8529
make dev-web
```
访问 http://localhost:5173 ,默认密码 `admin`**首次登录后请立即修改**)。
```bash
go test ./... # 运行单元测试
make build # 构建 bin/wormhole
```
### Agent
在管理台「客户端」页面获取 `verify_key`,于内网机器启动 Agent
```bash
./bin/wormhole agent -server <服务端IP>:8528 -key <verify_key>
```
## 部署
镜像由 Gitea Actions 自动构建并发布到 Container Registry**无需自行编译**。直接拉取运行即可。
```bash
docker pull git.rc707blog.top/rose_cat707/wormhole:latest
```
| Tag | 说明 |
|-----|------|
| `latest` | `main` / `master` 分支最新构建 |
| `sha-xxxxxxx` | 对应 commit 短 SHA |
| `v1.2.3` | 推送 Git tag `v1.2.3` 时发布 |
### docker run
```bash
docker pull git.rc707blog.top/rose_cat707/wormhole:latest
# 服务端
docker run -d --name wormhole \
--restart unless-stopped \
-p 8529:8529 -p 8528:8528 -p 8081:8081 -p 8443:8443 \
-v wormhole-data:/app/data \
git.rc707blog.top/rose_cat707/wormhole:latest
# Agent(同一镜像;与 Server 分开部署)
docker run -d --name wormhole-agent \
--restart=unless-stopped \
--network host \
git.rc707blog.top/rose_cat707/wormhole:latest \
agent -server <服务端IP>:8528 -key <verify_key>
```
> **注意**Agent 容器必须与 Server 分开部署。更新 Server 镜像时勿在同一 compose 中重建 AgentAgent 需 `--restart=unless-stopped`,否则收到 `SIGTERM` 后容器会保持 `Exited` 状态。
### 本地自行构建
```bash
docker build \
--build-arg IMAGE_PREFIX=docker.1panel.live/library/ \
--build-arg GOPROXY=https://goproxy.cn,direct \
-t wormhole:local .
```
**端口(默认)**
| 服务 | 端口 | 说明 |
|------|------|------|
| 管理 API / Web UI | 8529 | 浏览器访问管理台 |
| Agent Bridge | 8528 | 内网 Agent 长连接 |
| HTTP 域名反代 | 8081 | 公网 HTTP 入口 |
| HTTPS 域名反代 | 8443 | 公网 HTTPS 入口(需配置 TLS 证书) |
端口可在管理台「系统设置」中修改;**监听地址变更需重启服务**。
若通过域名 + HTTPS 访问管理台,可在前面加 Nginx/Caddy 反代到 `8529`。**勿将 Agent Bridge 8528 无防护暴露公网**;建议 VPN、IP 白名单或防火墙限制。
## 数据目录
数据默认存储在 `./data/wormhole.db`SQLite),Docker 部署时挂载为 `/app/data`
```
data/
└── wormhole.db # 客户端、隧道、域名、规则、用户、设置
```
可在「系统设置」中导入/导出配置,便于迁移与备份。
## 使用攻略(简要)
### 1. 首次登录
1. 打开 `http://<主机>:8529`
2. 默认账号 `admin` / `admin`,登录后进入「系统设置」修改密码
3. 顶栏切换界面语言(中文 / English)
### 2. 接入 Agent
1. 「客户端」→ 查看或复制 `verify_key`
2. 在内网机器运行 `wormhole agent -server <IP>:8528 -key <verify_key>`
3. 客户端上线后即可创建隧道或域名
### 3. 配置隧道
1. 「隧道」→「新建」,选择客户端、协议(TCP/UDP)与本地目标地址
2. 保存并启动;公网通过 `<服务器IP>:<映射端口>` 访问
3. 可选开启「访客登录」,适用于需认证的 Web 服务
### 4. 配置域名反代
1. 「域名」→「新建」,填写 Host、上游地址与 TLS(可选)
2. DNS 将域名解析到服务器公网 IP
3. 按需配置代理请求头(X-Forwarded-For 等)与访客验证
### 5. IP 安全
1. 「IP 规则」添加白名单 / 黑名单(支持 CIDR、正则)
2. 「请求 IP」查看访客来源,可疑 IP 可一键拉黑
3. 规则可按全局或指定资源(隧道/域名)生效
### 6. API 与自动化
- OpenAPI 规范:`http://<主机>:8529/api/openapi.json`
- 登录获取 Token`POST /api/auth/login`
- 管理端 API 前缀:`/api`,请求头 `Authorization: Bearer <token>`
### 7. 日常运维
| 操作 | 位置 |
|------|------|
| 查看流量与请求 | 监控大盘 |
| 管理 Agent | 客户端 |
| 启停隧道 / 域名 | 隧道、域名 |
| 导出配置备份 | 系统设置 |
| 修改端口 | 系统设置(需重启) |
## 命令行
| 命令 | 说明 |
|------|------|
| `wormhole server` | 启动服务端(默认 `-data ./data` |
| `wormhole agent -server <地址> -key <verify_key>` | 启动 Agent |
## 项目结构
```
cmd/wormhole/ # 进程入口(server / agent
internal/
api/ # HTTP 层与 OpenAPI
bridge/ # Agent 长连接与 Bridge
proxy/ # HTTP/TCP/UDP 反代与隧道
auth/ # JWT、访客认证、IP 转发
store/ # SQLite 与仓储
config/ # 配置模型与默认值
server/ # 服务编排
web/ # Vue 3 管理台
src/i18n/ # 多语言资源
src/views/ # 页面
docs/
image/ # README 界面截图
UI-DESIGN-SYSTEM.md
.gitea/workflows/ # Gitea Actions CI
```
## CI / 镜像发布
推送 `main` / `master` 或 tag `v*` 时,Gitea Actions 构建多架构镜像并推送到 Registry。工作流与 Dockerfile 约定见 [.gitea/README.md](.gitea/README.md)act_runner 部署参考见 [.gitea/act-runner/](.gitea/act-runner/)。
## 许可
[Wormhole 采用 MIT License](LICENSE)。
+50
View File
@@ -0,0 +1,50 @@
package main
import (
"fmt"
"os"
"github.com/wormhole/wormhole/internal/agent"
"github.com/wormhole/wormhole/internal/server"
)
func main() {
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
cmd := os.Args[1]
args := os.Args[2:]
switch cmd {
case "server", "serve", "s":
server.Run(args)
case "agent", "client", "a":
agent.Run(args)
case "help", "-h", "--help":
printUsage()
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n\n", cmd)
printUsage()
os.Exit(1)
}
}
func printUsage() {
fmt.Fprintf(os.Stderr, `Wormhole — 内网穿透 / 反向代理(统一二进制)
用法:
wormhole server 启动服务端(配置在 Web 管理台)
wormhole agent -server <地址> -key <verify_key> 启动客户端 Agent
简写:
wormhole serve / wormhole s
wormhole client / wormhole a
示例:
wormhole server
wormhole agent -server 127.0.0.1:8528 -key abc123
`)
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

+3
View File
@@ -0,0 +1,3 @@
# 项目级 Go 工具链环境(go mod / go build 会读取)
GOPROXY=https://goproxy.cn,direct
GOSUMDB=sum.golang.google.cn
+58
View File
@@ -0,0 +1,58 @@
module github.com/wormhole/wormhole
go 1.24.0
require (
github.com/gin-gonic/gin v1.10.0
github.com/glebarez/sqlite v1.11.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
gopkg.in/yaml.v3 v3.0.1
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/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // 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/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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/net v0.30.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
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)
+142
View File
@@ -0,0 +1,142 @@
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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
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/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
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/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
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/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/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
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/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+229
View File
@@ -0,0 +1,229 @@
package agent
import (
"crypto/tls"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net"
"os"
"os/signal"
"syscall"
"time"
"github.com/xtaci/smux"
"github.com/wormhole/wormhole/internal/bridge"
"github.com/wormhole/wormhole/internal/protocol"
"github.com/wormhole/wormhole/internal/version"
)
func Run(args []string) {
fs := flag.NewFlagSet("agent", flag.ExitOnError)
serverAddr := fs.String("server", "127.0.0.1:8528", "wormhole server bridge address")
verifyKey := fs.String("key", "", "client verify key")
useTLS := fs.Bool("tls", false, "use TLS for bridge connection")
fs.Parse(args)
if *verifyKey == "" {
log.Fatal("verify key required: -key")
}
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
backoff := time.Second
for {
select {
case <-sig:
log.Println("[agent] shutting down")
return
default:
}
shutdown, hadSession := runSession(*serverAddr, *verifyKey, *useTLS, sig)
if shutdown {
return
}
if hadSession {
backoff = time.Second
}
log.Printf("[agent] reconnecting in %v...", backoff)
select {
case <-sig:
log.Println("[agent] shutting down")
return
case <-time.After(backoff):
}
if backoff < 30*time.Second {
backoff *= 2
}
}
}
func dialBridge(addr string, useTLS bool) (net.Conn, error) {
var conn net.Conn
var err error
if useTLS {
conn, err = tls.Dial("tcp", addr, &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: true,
})
} else {
conn, err = net.Dial("tcp", addr)
}
if err != nil {
return nil, err
}
bridge.SetTCPKeepAlive(conn)
return conn, nil
}
// runSession connects and blocks until disconnect.
// Returns shutdown=true when SIGINT/SIGTERM received; hadSession=true if registration succeeded.
func runSession(serverAddr, verifyKey string, useTLS bool, sig chan os.Signal) (shutdown, hadSession bool) {
conn, err := dialBridge(serverAddr, useTLS)
if err != nil {
log.Printf("[agent] dial server: %v", err)
return false, false
}
defer conn.Close()
if err := protocol.WriteMessage(conn, protocol.MsgRegister, protocol.RegisterPayload{
VerifyKey: verifyKey,
Version: version.Version,
}); err != nil {
log.Printf("[agent] register: %v", err)
return false, false
}
msgType, body, err := protocol.ReadMessage(conn)
if err != nil {
log.Printf("[agent] register ack: %v", err)
return false, false
}
if msgType != protocol.MsgRegisterAck {
log.Printf("[agent] unexpected register response")
return false, false
}
var ack protocol.RegisterAckPayload
if len(body) > 0 {
if err := json.Unmarshal(body, &ack); err != nil {
log.Printf("[agent] parse ack: %v", err)
return false, false
}
}
if !ack.OK {
log.Printf("[agent] register failed: %s", ack.Message)
return false, false
}
log.Printf("[agent] registered as client %d", ack.ClientID)
hadSession = true
session, err := smux.Server(conn, bridge.SmuxConfig())
if err != nil {
log.Printf("[agent] smux: %v", err)
return false, hadSession
}
defer session.Close()
sessionDead := make(chan string, 1)
go func() {
reason := heartbeat(session)
sessionDead <- reason
}()
go func() {
for {
stream, err := session.AcceptStream()
if err != nil {
select {
case sessionDead <- "accept stream: " + err.Error():
default:
}
return
}
go handleStream(stream)
}
}()
select {
case <-sig:
return true, hadSession
case reason := <-sessionDead:
log.Printf("[agent] session closed (%s)", reason)
return false, hadSession
}
}
const heartbeatMaxFailures = 3
func heartbeat(session *smux.Session) string {
ticker := time.NewTicker(bridge.HeartbeatInterval)
defer ticker.Stop()
failures := 0
for range ticker.C {
if err := pingOnce(session); err != nil {
failures++
log.Printf("[agent] heartbeat failed (%d/%d): %v", failures, heartbeatMaxFailures, err)
if failures >= heartbeatMaxFailures {
return "heartbeat timeout"
}
continue
}
failures = 0
}
return "heartbeat stopped"
}
func pingOnce(session *smux.Session) error {
stream, err := session.OpenStream()
if err != nil {
return err
}
defer stream.Close()
if err := protocol.WriteMessage(stream, protocol.MsgPing, nil); err != nil {
return err
}
msgType, _, err := protocol.ReadMessage(stream)
if err != nil {
return err
}
if msgType != protocol.MsgPong {
return fmt.Errorf("unexpected response type %d", msgType)
}
return nil
}
func handleStream(stream *smux.Stream) {
defer stream.Close()
msgType, body, err := protocol.ReadMessage(stream)
if err != nil {
return
}
switch msgType {
case protocol.MsgPing:
_ = protocol.WriteMessage(stream, protocol.MsgPong, nil)
return
case protocol.MsgOpenStream:
handleDataStream(stream, body)
}
}
func handleDataStream(stream *smux.Stream, body []byte) {
meta, err := protocol.ParseLinkMeta(body)
if err != nil {
return
}
target, err := net.Dial("tcp", meta.Target)
if err != nil {
return
}
defer target.Close()
_ = protocol.WriteMessage(stream, protocol.MsgStreamReady, nil)
go func() {
_, _ = io.Copy(target, stream)
}()
_, _ = io.Copy(stream, target)
}
+174
View File
@@ -0,0 +1,174 @@
package api
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/wormhole/wormhole/internal/auth"
"github.com/wormhole/wormhole/internal/store"
)
func (s *Server) dashboardRankings(c *gin.Context) {
rankType := c.DefaultQuery("type", "tunnels")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 50
}
if pageSize > 500 {
pageSize = 500
}
offset := (page - 1) * pageSize
q := c.Query("q")
switch rankType {
case "tunnels":
items, total, err := s.store.ListTunnelsByFlow(pageSize, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
case "hosts":
items, total, err := s.store.ListHostsByFlow(pageSize, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
case "ips":
items, total, err := s.store.ListAllRequestIPs(pageSize, offset, q)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": requestIPViews(s.store, 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)
}
+348
View File
@@ -0,0 +1,348 @@
package api
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/wormhole/wormhole/internal/auth"
"github.com/wormhole/wormhole/internal/store"
)
type importResult struct {
Created int `json:"created"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
Errors []string `json:"errors"`
}
type tunnelImportRequest struct {
Mode string `json:"mode"` // create, upsert, skip
Items []store.TunnelExportItem `json:"items"`
}
type hostImportRequest struct {
Mode string `json:"mode"`
Items []store.HostExportItem `json:"items"`
}
func (s *Server) exportTunnels(c *gin.Context) {
claims := getClaims(c)
clientID, _ := strconv.ParseUint(c.Query("client_id"), 10, 64)
tunnels, err := s.store.ListTunnels(uint(clientID), claims.UserID, auth.IsAdmin(claims.Role))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
clientNames := tunnelClientNames(s.store, tunnels)
items := make([]store.TunnelExportItem, 0, len(tunnels))
for _, t := range tunnels {
items = append(items, store.TunnelToExportItem(t, clientNames[t.ClientID]))
}
doc := store.TunnelExportDoc{
Version: store.ExportVersion,
ExportedAt: time.Now().UTC(),
Items: items,
}
c.Header("Content-Disposition", `attachment; filename="wormhole-tunnels.json"`)
c.JSON(http.StatusOK, doc)
}
func (s *Server) importTunnels(c *gin.Context) {
claims := getClaims(c)
var req tunnelImportRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
return
}
if len(req.Items) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "items is empty"})
return
}
mode := req.Mode
if mode == "" {
mode = "upsert"
}
result := importResult{}
for i, item := range req.Items {
clientID, err := store.ResolveImportClientID(s.store, item.ClientID, item.ClientName, claims.UserID, auth.IsAdmin(claims.Role))
if err != nil {
result.Failed++
result.Errors = append(result.Errors, formatImportError("tunnel", i+1, err))
continue
}
t := tunnelFromImportItem(item, clientID)
if err := normalizeTunnel(&t); err != nil {
result.Failed++
result.Errors = append(result.Errors, formatImportError("tunnel", i+1, err))
continue
}
existing, err := s.store.FindTunnelByPort(t.ListenPort)
switch mode {
case "create":
if err == nil {
result.Skipped++
continue
}
if err := s.store.CreateTunnel(&t); err != nil {
result.Failed++
result.Errors = append(result.Errors, formatImportError("tunnel", i+1, err))
continue
}
s.cache.UpsertTunnel(&t)
if t.Status {
_ = s.proxy.StartTunnel(t.ID)
}
result.Created++
case "skip":
if err == nil {
result.Skipped++
continue
}
if err := s.store.CreateTunnel(&t); err != nil {
result.Failed++
result.Errors = append(result.Errors, formatImportError("tunnel", i+1, err))
continue
}
s.cache.UpsertTunnel(&t)
if t.Status {
_ = s.proxy.StartTunnel(t.ID)
}
result.Created++
default: // upsert
if err != nil {
if err := s.store.CreateTunnel(&t); err != nil {
result.Failed++
result.Errors = append(result.Errors, formatImportError("tunnel", i+1, err))
continue
}
s.cache.UpsertTunnel(&t)
if t.Status {
_ = s.proxy.StartTunnel(t.ID)
}
result.Created++
continue
}
wasRunning := existing.RunStatus
if wasRunning {
_ = s.proxy.StopTunnel(existing.ID)
}
t.ID = existing.ID
t.ClientID = existing.ClientID
t.RunStatus = false
if err := s.store.UpdateTunnel(&t); err != nil {
result.Failed++
result.Errors = append(result.Errors, formatImportError("tunnel", i+1, err))
continue
}
s.cache.UpsertTunnel(&t)
if t.Status {
_ = s.proxy.StartTunnel(t.ID)
}
result.Updated++
}
}
c.JSON(http.StatusOK, result)
}
func (s *Server) exportHosts(c *gin.Context) {
claims := getClaims(c)
clientID, _ := strconv.ParseUint(c.Query("client_id"), 10, 64)
hosts, err := s.store.ListHosts(uint(clientID), claims.UserID, auth.IsAdmin(claims.Role))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
clientNames := hostClientNames(s.store, hosts)
items := make([]store.HostExportItem, 0, len(hosts))
for _, h := range hosts {
items = append(items, store.HostToExportItem(h, clientNames[h.ClientID]))
}
doc := store.HostExportDoc{
Version: store.ExportVersion,
ExportedAt: time.Now().UTC(),
Items: items,
}
c.Header("Content-Disposition", `attachment; filename="wormhole-hosts.json"`)
c.JSON(http.StatusOK, doc)
}
func (s *Server) importHosts(c *gin.Context) {
claims := getClaims(c)
var req hostImportRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
return
}
if len(req.Items) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "items is empty"})
return
}
mode := req.Mode
if mode == "" {
mode = "upsert"
}
result := importResult{}
for i, item := range req.Items {
clientID, err := store.ResolveImportClientID(s.store, item.ClientID, item.ClientName, claims.UserID, auth.IsAdmin(claims.Role))
if err != nil {
result.Failed++
result.Errors = append(result.Errors, formatImportError("host", i+1, err))
continue
}
h := hostFromImportItem(item, clientID)
if err := normalizeHost(&h); err != nil {
result.Failed++
result.Errors = append(result.Errors, formatImportError("host", i+1, err))
continue
}
existing, err := s.store.FindHostByRouteGlobal(h.Host, h.Location)
switch mode {
case "create":
if err == nil {
result.Skipped++
continue
}
if err := s.store.CreateHost(&h); err != nil {
result.Failed++
result.Errors = append(result.Errors, formatImportError("host", i+1, err))
continue
}
s.cache.UpsertHost(&h)
result.Created++
case "skip":
if err == nil {
result.Skipped++
continue
}
if err := s.store.CreateHost(&h); err != nil {
result.Failed++
result.Errors = append(result.Errors, formatImportError("host", i+1, err))
continue
}
s.cache.UpsertHost(&h)
result.Created++
default:
if err != nil {
if err := s.store.CreateHost(&h); err != nil {
result.Failed++
result.Errors = append(result.Errors, formatImportError("host", i+1, err))
continue
}
s.cache.UpsertHost(&h)
result.Created++
continue
}
h.ID = existing.ID
h.ClientID = existing.ClientID
if err := s.store.UpdateHost(&h); err != nil {
result.Failed++
result.Errors = append(result.Errors, formatImportError("host", i+1, err))
continue
}
s.cache.UpsertHost(&h)
result.Updated++
}
}
c.JSON(http.StatusOK, result)
}
func tunnelFromImportItem(item store.TunnelExportItem, clientID uint) store.Tunnel {
return store.Tunnel{
ClientID: clientID,
Name: item.Name,
Mode: item.Mode,
ListenIP: item.ListenIP,
ListenPort: item.ListenPort,
TargetAddr: item.TargetAddr,
IPForwardMode: item.IPForwardMode,
CustomHeaders: item.CustomHeaders,
Status: item.Status,
}
}
func hostFromImportItem(item store.HostExportItem, clientID uint) store.Host {
return store.Host{
ClientID: clientID,
Host: item.Host,
Location: item.Location,
Scheme: item.Scheme,
TargetAddr: item.TargetAddr,
AutoHTTPS: item.AutoHTTPS,
IPForwardMode: item.IPForwardMode,
CustomHeaders: item.CustomHeaders,
Status: item.Status,
}
}
func normalizeTunnel(t *store.Tunnel) error {
if t.ListenIP == "" {
t.ListenIP = "0.0.0.0"
}
if t.Mode == "" {
t.Mode = "tcp"
}
if t.ListenPort <= 0 || t.TargetAddr == "" {
return errImportField("listen_port and target_addr required")
}
if t.IPForwardMode == "" {
t.IPForwardMode = "replace"
}
return nil
}
func normalizeHost(h *store.Host) error {
if h.Host == "" || h.TargetAddr == "" {
return errImportField("host and target_addr required")
}
if h.Location == "" {
h.Location = "/"
}
if h.Scheme == "" {
h.Scheme = "all"
}
if h.IPForwardMode == "" {
h.IPForwardMode = "replace"
}
return nil
}
type importFieldError string
func errImportField(msg string) error { return importFieldError(msg) }
func (e importFieldError) Error() string { return string(e) }
func formatImportError(kind string, index int, err error) string {
return kind + " #" + strconv.Itoa(index) + ": " + err.Error()
}
func tunnelClientNames(st *store.Store, tunnels []store.Tunnel) map[uint]string {
ids := make(map[uint]struct{})
for _, t := range tunnels {
ids[t.ClientID] = struct{}{}
}
names := make(map[uint]string, len(ids))
for id := range ids {
if c, err := st.GetClientByID(id); err == nil {
names[id] = c.Name
}
}
return names
}
func hostClientNames(st *store.Store, hosts []store.Host) map[uint]string {
ids := make(map[uint]struct{})
for _, h := range hosts {
ids[h.ClientID] = struct{}{}
}
names := make(map[uint]string, len(ids))
for id := range ids {
if c, err := st.GetClientByID(id); err == nil {
names[id] = c.Name
}
}
return names
}
+56
View File
@@ -0,0 +1,56 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/wormhole/wormhole/internal/auth"
"github.com/wormhole/wormhole/internal/store"
)
func (s *Server) authMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if key := c.GetHeader("X-API-Key"); key != "" {
user, err := s.store.ValidateAPIKey(key)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid api key"})
return
}
c.Set("claims", &auth.Claims{
UserID: user.ID,
Username: user.Username,
Role: user.Role,
})
c.Set("auth_via", "apikey")
c.Next()
return
}
token := c.GetHeader("Authorization")
if len(token) > 7 && token[:7] == "Bearer " {
token = token[7:]
} else {
token = c.Query("token")
}
if token == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
claims, err := s.auth.ParseToken(token)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
c.Set("claims", claims)
c.Set("auth_via", "jwt")
c.Next()
}
}
func pageParams(c *gin.Context) store.PageParams {
return store.ParsePageQuery(
c.DefaultQuery("page", "1"),
c.DefaultQuery("page_size", "20"),
c.Query("q"),
)
}
+97
View File
@@ -0,0 +1,97 @@
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 }
}
}
},
"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" } } } }
}
}`
+921
View File
@@ -0,0 +1,921 @@
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("/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
}
if tops, ok := data["top_ips"].([]store.RequestIP); ok {
data["top_ips"] = requestIPViews(s.store, tops)
}
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.HostRouteConflict(req.Host.Host, req.Location, 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.HostRouteConflict(req.Host.Host, req.Location, 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": requestIPViews(s.store, 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": requestIPViews(s.store, items), "total": total})
}
func (s *Server) blockIP(c *gin.Context) {
var req struct {
IP string `json:"ip"`
Scope string `json:"scope"`
ResourceType string `json:"resource_type"`
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
}
scope := req.Scope
if scope == "" {
scope = req.ResourceType
}
if scope == "" {
scope = "global"
}
if err := s.security.BlockIP(req.IP, 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 (s *Server) createUser(c *gin.Context) {
claims := getClaims(c)
if !auth.IsAdmin(claims.Role) {
c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
Role string `json:"role"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
return
}
hash, err := store.HashPassword(req.Password)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if req.Role == "" {
req.Role = "visitor"
}
if req.Role != "visitor" {
c.JSON(http.StatusBadRequest, gin.H{"error": "仅支持创建访客用户"})
return
}
user := store.User{
Username: req.Username,
PasswordHash: hash,
Role: req.Role,
Status: true,
}
if err := s.store.CreateUser(&user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, user)
}
func (s *Server) updateUser(c *gin.Context) {
claims := getClaims(c)
if !auth.IsAdmin(claims.Role) {
c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
return
}
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
user, err := s.store.GetUserByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if user.Role != "visitor" {
c.JSON(http.StatusBadRequest, gin.H{"error": "仅可编辑访客用户"})
return
}
var req struct {
Password string `json:"password"`
Role string `json:"role"`
Status *bool `json:"status"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
return
}
if req.Password != "" {
hash, err := store.HashPassword(req.Password)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
user.PasswordHash = hash
}
if req.Role != "" {
if req.Role != "visitor" {
c.JSON(http.StatusBadRequest, gin.H{"error": "仅支持访客用户"})
return
}
user.Role = req.Role
}
if req.Status != nil {
user.Status = *req.Status
}
if err := s.store.UpdateUser(user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, user)
}
func (s *Server) deleteUser(c *gin.Context) {
claims := getClaims(c)
if !auth.IsAdmin(claims.Role) {
c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
return
}
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
user, err := s.store.GetUserByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if user.Role != "visitor" {
c.JSON(http.StatusBadRequest, gin.H{"error": "仅可删除访客用户"})
return
}
if err := s.store.DeleteUser(uint(id), claims.UserID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+54
View File
@@ -0,0 +1,54 @@
package api
import (
"net/http"
"reflect"
"github.com/gin-gonic/gin"
"github.com/wormhole/wormhole/internal/auth"
"github.com/wormhole/wormhole/internal/config"
)
func (s *Server) getSettings(c *gin.Context) {
claims := getClaims(c)
if !auth.IsAdmin(claims.Role) {
c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
return
}
dto := s.cfg.ToDTO()
c.JSON(http.StatusOK, gin.H{
"settings": dto,
"note": "修改监听端口后需重启 wormhole server 生效",
})
}
func (s *Server) updateSettings(c *gin.Context) {
claims := getClaims(c)
if !auth.IsAdmin(claims.Role) {
c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
return
}
var req config.SettingsDTO
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
return
}
oldServer := s.cfg.Server
newCfg := config.SettingsFromDTO(req, s.cfg)
if err := s.store.SaveConfig(newCfg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
*s.cfg = *newCfg
s.auth.ReloadAuth(&s.cfg.Auth)
s.resAuth.ReloadAuth(&s.cfg.Auth)
restartRequired := !reflect.DeepEqual(oldServer, s.cfg.Server) ||
oldServer.TLSCertFile != s.cfg.Server.TLSCertFile ||
oldServer.TLSKeyFile != s.cfg.Server.TLSKeyFile
c.JSON(http.StatusOK, gin.H{
"settings": s.cfg.ToDTO(),
"restart_required": restartRequired,
})
}
+147
View File
@@ -0,0 +1,147 @@
package api
import (
"fmt"
"net/http"
"strconv"
"time"
"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()})
}
type requestIPView struct {
ID uint `json:"id"`
ResourceType string `json:"resource_type"`
ResourceID uint `json:"resource_id"`
ResourceLabel string `json:"resource_label"`
IP string `json:"ip"`
HitCount int64 `json:"hit_count"`
LastSeenAt time.Time `json:"last_seen_at"`
}
func requestIPViews(st *store.Store, items []store.RequestIP) []requestIPView {
out := make([]requestIPView, 0, len(items))
for _, item := range items {
label := visitorResourceLabel(st, item.ResourceType, item.ResourceID)
if label == "" && item.ResourceID > 0 {
label = fmt.Sprintf("#%d", item.ResourceID)
}
out = append(out, requestIPView{
ID: item.ID,
ResourceType: item.ResourceType,
ResourceID: item.ResourceID,
ResourceLabel: label,
IP: item.IP,
HitCount: item.HitCount,
LastSeenAt: item.LastSeenAt,
})
}
return out
}
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 ""
}
}
+55
View File
@@ -0,0 +1,55 @@
package auth
import (
"net"
"net/http"
"strings"
)
// DirectRemoteIP returns the direct TCP peer address, ignoring X-Forwarded-For.
// Use for security decisions (login rate limit, IP rules, resource auth).
func DirectRemoteIP(r *http.Request) string {
return HostOnly(r.RemoteAddr)
}
// AddrHost extracts the host portion from a net.Addr string (host:port or [host]:port).
func AddrHost(addr net.Addr) string {
if addr == nil {
return ""
}
return HostOnly(addr.String())
}
// HostOnly strips the port from an address string.
func HostOnly(addr string) string {
if addr == "" {
return ""
}
host, _, err := net.SplitHostPort(addr)
if err != nil {
return strings.Trim(addr, "[]")
}
return host
}
// ExtractRemoteIP returns the client IP, honoring X-Forwarded-For when present.
// Use only when forwarding visitor IP to upstream backends behind a trusted proxy.
func ExtractRemoteIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
return strings.TrimSpace(parts[0])
}
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return strings.TrimSpace(xri)
}
return DirectRemoteIP(r)
}
// SafeRedirectPath validates a post-login redirect path (relative only).
func SafeRedirectPath(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" || !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") {
return "/"
}
return raw
}
+118
View File
@@ -0,0 +1,118 @@
package auth
import (
"errors"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/wormhole/wormhole/internal/config"
"github.com/wormhole/wormhole/internal/store"
)
type Claims struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
type Service struct {
mu sync.RWMutex
cfg *config.AuthConfig
limiter *LoginLimiter
}
func NewService(cfg *config.AuthConfig) *Service {
return &Service{
cfg: cfg,
limiter: NewLoginLimiter(cfg),
}
}
func (s *Service) ReloadAuth(cfg *config.AuthConfig) {
s.mu.Lock()
defer s.mu.Unlock()
*s.cfg = *cfg
s.limiter = NewLoginLimiter(cfg)
}
func (s *Service) Login(st *store.Store, ip, username, password string) (string, *store.User, error) {
s.mu.RLock()
limiter := s.limiter
s.mu.RUnlock()
if retry, blocked := limiter.IsBlocked(ip, username); blocked {
return "", nil, &LoginBlockedError{RetryAfter: retry}
}
user, err := st.GetUserByUsername(username)
if err != nil {
limiter.RecordFailure(ip, username)
time.Sleep(300 * time.Millisecond)
return "", nil, errors.New("invalid credentials")
}
if !user.Status {
limiter.RecordFailure(ip, username)
time.Sleep(300 * time.Millisecond)
return "", nil, errors.New("invalid credentials")
}
if !store.CheckPassword(user.PasswordHash, password) {
limiter.RecordFailure(ip, username)
time.Sleep(300 * time.Millisecond)
return "", nil, errors.New("invalid credentials")
}
if user.Role == "visitor" {
limiter.RecordFailure(ip, username)
time.Sleep(300 * time.Millisecond)
return "", nil, errors.New("访客账号无法登录管理后台")
}
limiter.Reset(ip, username)
token, err := s.GenerateToken(user)
if err != nil {
return "", nil, err
}
return token, user, nil
}
func (s *Service) GenerateToken(user *store.User) (string, error) {
s.mu.RLock()
cfg := s.cfg
s.mu.RUnlock()
claims := Claims{
UserID: user.ID,
Username: user.Username,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(cfg.TokenTTL)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(cfg.JWTSecret))
}
func (s *Service) ParseToken(tokenStr string) (*Claims, error) {
s.mu.RLock()
secret := s.cfg.JWTSecret
s.mu.RUnlock()
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
return []byte(secret), nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}
func IsAdmin(role string) bool {
return role == "admin"
}
func IsVisitor(role string) bool {
return role == "visitor"
}
+123
View File
@@ -0,0 +1,123 @@
package auth
import (
"sync"
"time"
"github.com/wormhole/wormhole/internal/config"
)
type LoginBlockedError struct {
RetryAfter time.Duration
}
func (e *LoginBlockedError) Error() string {
return "too many login attempts"
}
type loginRecord struct {
failures int
windowStart time.Time
lockedUntil time.Time
}
type LoginLimiter struct {
mu sync.Mutex
maxAttempts int
window time.Duration
lockout time.Duration
records map[string]*loginRecord
}
func NewLoginLimiter(cfg *config.AuthConfig) *LoginLimiter {
if cfg.LoginMaxAttempts <= 0 {
return nil
}
max := cfg.LoginMaxAttempts
window := cfg.LoginWindow
lockout := cfg.LoginLockout
if window <= 0 {
window = 5 * time.Minute
}
if lockout <= 0 {
lockout = 15 * time.Minute
}
return &LoginLimiter{
maxAttempts: max,
window: window,
lockout: lockout,
records: make(map[string]*loginRecord),
}
}
func (l *LoginLimiter) keys(ip, username string) []string {
keys := []string{"ip:" + ip}
if username != "" {
keys = append(keys, "user:"+username)
}
return keys
}
func (l *LoginLimiter) IsBlocked(ip, username string) (time.Duration, bool) {
if l == nil {
return 0, false
}
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
var maxRetry time.Duration
for _, key := range l.keys(ip, username) {
rec := l.records[key]
if rec == nil {
continue
}
if now.Before(rec.lockedUntil) {
retry := time.Until(rec.lockedUntil)
if retry > maxRetry {
maxRetry = retry
}
}
}
return maxRetry, maxRetry > 0
}
func (l *LoginLimiter) RecordFailure(ip, username string) {
if l == nil {
return
}
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
for _, key := range l.keys(ip, username) {
rec := l.records[key]
if rec == nil {
rec = &loginRecord{windowStart: now}
l.records[key] = rec
}
if now.Before(rec.lockedUntil) {
continue
}
if now.Sub(rec.windowStart) > l.window {
rec.failures = 0
rec.windowStart = now
}
rec.failures++
if rec.failures >= l.maxAttempts {
rec.lockedUntil = now.Add(l.lockout)
rec.failures = 0
}
}
}
func (l *LoginLimiter) Reset(ip, username string) {
if l == nil {
return
}
l.mu.Lock()
defer l.mu.Unlock()
for _, key := range l.keys(ip, username) {
delete(l.records, key)
}
}
+59
View File
@@ -0,0 +1,59 @@
package auth
import (
"errors"
"testing"
"time"
"github.com/wormhole/wormhole/internal/config"
)
func TestLoginLimiterBlocksAfterMaxAttempts(t *testing.T) {
l := NewLoginLimiter(&config.AuthConfig{
LoginMaxAttempts: 3,
LoginWindow: time.Minute,
LoginLockout: 10 * time.Minute,
})
ip := "192.168.1.1"
user := "admin"
for i := 0; i < 3; i++ {
if _, blocked := l.IsBlocked(ip, user); blocked {
t.Fatalf("unexpected block at attempt %d", i+1)
}
l.RecordFailure(ip, user)
}
retry, blocked := l.IsBlocked(ip, user)
if !blocked || retry <= 0 {
t.Fatalf("expected block after max attempts, retry=%v blocked=%v", retry, blocked)
}
}
func TestLoginLimiterResetClearsBlock(t *testing.T) {
l := NewLoginLimiter(&config.AuthConfig{
LoginMaxAttempts: 2,
LoginWindow: time.Minute,
LoginLockout: time.Minute,
})
ip := "10.0.0.1"
l.RecordFailure(ip, "u1")
l.RecordFailure(ip, "u1")
if _, blocked := l.IsBlocked(ip, "u1"); !blocked {
t.Fatal("expected blocked")
}
l.Reset(ip, "u1")
if _, blocked := l.IsBlocked(ip, "u1"); blocked {
t.Fatal("expected unblocked after reset")
}
}
func TestLoginBlockedError(t *testing.T) {
err := &LoginBlockedError{RetryAfter: 30 * time.Second}
if !errors.As(err, new(*LoginBlockedError)) {
t.Fatal("LoginBlockedError should be detectable via errors.As")
}
}
+180
View File
@@ -0,0 +1,180 @@
package auth
import (
"crypto/subtle"
"fmt"
"net"
"net/http"
"sync"
"time"
"github.com/wormhole/wormhole/internal/cache"
"github.com/wormhole/wormhole/internal/config"
"github.com/wormhole/wormhole/internal/store"
)
const accessCookieName = "wh_access"
type ResourceAuth struct {
cache *cache.Manager
store *store.Store
jwtSecret string
limiter *LoginLimiter
mu sync.RWMutex
grants map[string]time.Time
}
func NewResourceAuth(c *cache.Manager, st *store.Store, authCfg *config.AuthConfig) *ResourceAuth {
ra := &ResourceAuth{
cache: c,
store: st,
jwtSecret: authCfg.JWTSecret,
limiter: NewLoginLimiter(authCfg),
grants: make(map[string]time.Time),
}
go ra.cleanupLoop()
return ra
}
func (ra *ResourceAuth) ReloadAuth(cfg *config.AuthConfig) {
ra.mu.Lock()
defer ra.mu.Unlock()
ra.jwtSecret = cfg.JWTSecret
ra.limiter = NewLoginLimiter(cfg)
}
func grantKey(resourceType string, resourceID uint, ip string) string {
return fmt.Sprintf("%s:%d:%s", resourceType, resourceID, ip)
}
func (ra *ResourceAuth) cleanupLoop() {
ticker := time.NewTicker(time.Minute)
for range ticker.C {
now := time.Now()
ra.mu.Lock()
for k, exp := range ra.grants {
if now.After(exp) {
delete(ra.grants, k)
}
}
ra.mu.Unlock()
}
}
func (ra *ResourceAuth) GrantAccess(resourceType string, resourceID uint, ip string, ttl time.Duration) {
ra.mu.Lock()
defer ra.mu.Unlock()
ra.grants[grantKey(resourceType, resourceID, ip)] = time.Now().Add(ttl)
}
func (ra *ResourceAuth) HasAccess(resourceType string, resourceID uint, ip string) bool {
ra.mu.RLock()
defer ra.mu.RUnlock()
exp, ok := ra.grants[grantKey(resourceType, resourceID, ip)]
return ok && time.Now().Before(exp)
}
func (ra *ResourceAuth) CheckHTTP(w http.ResponseWriter, r *http.Request, resourceType string, resourceID uint) bool {
cfg, userIDs, ok := GetVisitorConfig(ra.cache, resourceType, resourceID)
if ok && cfg.Enabled {
return ra.CheckVisitorHTTP(w, r, resourceType, resourceID, cfg, userIDs)
}
ip := DirectRemoteIP(r)
if ra.HasAccess(resourceType, resourceID, ip) {
return true
}
policy, ok := ra.cache.GetAuthPolicy(resourceType, resourceID)
if !ok || !policy.Enabled {
return true
}
switch policy.Type {
case "basic":
_, pass, ok := r.BasicAuth()
if ok && subtle.ConstantTimeCompare([]byte(pass), []byte(policy.Password)) == 1 {
ra.GrantAccess(resourceType, resourceID, ip, time.Duration(policy.TTLSeconds)*time.Second)
return true
}
w.Header().Set("WWW-Authenticate", `Basic realm="Wormhole"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return false
case "form":
if r.URL.Path == "/_wormhole/auth" && r.Method == http.MethodPost {
_ = r.ParseForm()
pass := r.FormValue("password")
if subtle.ConstantTimeCompare([]byte(pass), []byte(policy.Password)) == 1 {
ra.GrantAccess(resourceType, resourceID, ip, time.Duration(policy.TTLSeconds)*time.Second)
http.SetCookie(w, &http.Cookie{Name: accessCookieName, Value: "1", Path: "/", MaxAge: policy.TTLSeconds})
redirect := SafeRedirectPath(r.FormValue("redirect"))
http.Redirect(w, r, redirect, http.StatusFound)
return false
}
}
if cookie, err := r.Cookie(accessCookieName); err == nil && cookie.Value == "1" {
ra.GrantAccess(resourceType, resourceID, ip, time.Duration(policy.TTLSeconds)*time.Second)
return true
}
redirect := r.URL.RequestURI()
html := fmt.Sprintf(`<!DOCTYPE html><html><body>
<form method="POST" action="/_wormhole/auth">
<input type="hidden" name="redirect" value="%s"/>
<input type="password" name="password" placeholder="Password"/>
<button type="submit">Login</button>
</form></body></html>`, redirect)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(html))
return false
case "callback":
if policy.CallbackURL != "" {
token := r.URL.Query().Get("token")
if token != "" && token == policy.Password {
ra.GrantAccess(resourceType, resourceID, ip, time.Duration(policy.TTLSeconds)*time.Second)
return true
}
cb := policy.CallbackURL
if stringsContains(cb, "?") {
cb += "&"
} else {
cb += "?"
}
cb += "redirect=" + r.URL.RequestURI()
http.Redirect(w, r, cb, http.StatusFound)
return false
}
}
return true
}
func (ra *ResourceAuth) CheckTCP(remoteAddr net.Addr, resourceType string, resourceID uint) bool {
ip := remoteAddr.String()
if host, _, err := net.SplitHostPort(ip); err == nil {
ip = host
}
if ra.HasAccess(resourceType, resourceID, ip) {
return true
}
policy, ok := ra.cache.GetAuthPolicy(resourceType, resourceID)
if !ok || !policy.Enabled {
return true
}
return false
}
func stringsContains(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(sub) == 0 || indexOf(s, sub) >= 0)
}
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
func (ra *ResourceAuth) CreatePolicy(st *store.Store, p *store.AuthPolicy) error {
return st.CreateAuthPolicy(p)
}
+337
View File
@@ -0,0 +1,337 @@
package auth
import (
"errors"
"fmt"
"html"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/wormhole/wormhole/internal/cache"
"github.com/wormhole/wormhole/internal/store"
)
const (
visitorLoginPath = "/_wormhole/login"
visitorCookie = "wh_visitor"
)
type VisitorClaims struct {
UserID uint `json:"user_id"`
ResourceType string `json:"resource_type"`
ResourceID uint `json:"resource_id"`
jwt.RegisteredClaims
}
type VisitorConfig struct {
Enabled bool
BindMode string
UserIDs []uint
TTLSeconds int
}
func visitorConfigFromHost(h *store.Host) VisitorConfig {
ttl := h.VisitorTTLSeconds
if ttl <= 0 {
ttl = 7200
}
return VisitorConfig{
Enabled: h.VisitorAuthEnabled,
BindMode: h.VisitorBindMode,
TTLSeconds: ttl,
}
}
func visitorConfigFromTunnel(t *store.Tunnel) VisitorConfig {
ttl := t.VisitorTTLSeconds
if ttl <= 0 {
ttl = 7200
}
return VisitorConfig{
Enabled: t.VisitorAuthEnabled,
BindMode: t.VisitorBindMode,
TTLSeconds: ttl,
}
}
func (ra *ResourceAuth) CheckVisitorHTTP(w http.ResponseWriter, r *http.Request, resourceType string, resourceID uint, cfg VisitorConfig, userIDs []uint) bool {
if !cfg.Enabled {
return true
}
cfg.UserIDs = userIDs
if bindMode := strings.TrimSpace(cfg.BindMode); bindMode == "" {
cfg.BindMode = "all"
} else {
cfg.BindMode = bindMode
}
if r.URL.Path == visitorLoginPath {
ra.handleVisitorLogin(w, r, resourceType, resourceID, cfg)
return false
}
if claims, ok := ra.parseVisitorCookie(r, resourceType, resourceID); ok {
if ra.visitorAllowed(claims.UserID, cfg) {
ra.logVisitorAccess(claims.UserID, resourceType, resourceID, "access", r)
return true
}
}
redirect := r.URL.RequestURI()
http.Redirect(w, r, visitorLoginPath+"?redirect="+url.QueryEscape(redirect), http.StatusFound)
return false
}
func visitorLimiterIP(resourceType string, resourceID uint, ip string) string {
return fmt.Sprintf("%s:%d:%s", resourceType, resourceID, ip)
}
func (ra *ResourceAuth) handleVisitorLogin(w http.ResponseWriter, r *http.Request, resourceType string, resourceID uint, cfg VisitorConfig) {
ip := DirectRemoteIP(r)
scopedIP := visitorLimiterIP(resourceType, resourceID, ip)
switch r.Method {
case http.MethodPost:
_ = r.ParseForm()
username := strings.TrimSpace(r.FormValue("username"))
if retry, blocked := ra.limiter.IsBlocked(scopedIP, username); blocked {
ra.renderVisitorLoginBlocked(w, r, cfg, retry)
return
}
password := r.FormValue("password")
user, err := ra.authenticateVisitor(username, password, cfg)
if err != nil {
ra.limiter.RecordFailure(scopedIP, username)
time.Sleep(300 * time.Millisecond)
ra.renderVisitorLogin(w, r, cfg, "用户名或密码错误")
return
}
ra.limiter.Reset(scopedIP, username)
token, err := ra.signVisitorToken(user.ID, resourceType, resourceID, cfg.TTLSeconds)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
http.SetCookie(w, &http.Cookie{
Name: visitorCookie,
Value: token,
Path: "/",
MaxAge: cfg.TTLSeconds,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: secure,
})
redirect := SafeRedirectPath(r.FormValue("redirect"))
ra.logVisitorAccess(user.ID, resourceType, resourceID, "login", r)
http.Redirect(w, r, redirect, http.StatusFound)
case http.MethodGet:
if retry, blocked := ra.limiter.IsBlocked(scopedIP, ""); blocked {
ra.renderVisitorLoginBlocked(w, r, cfg, retry)
return
}
ra.renderVisitorLogin(w, r, cfg, "")
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (ra *ResourceAuth) authenticateVisitor(username, password string, cfg VisitorConfig) (*store.User, error) {
if username == "" || password == "" {
return nil, errors.New("invalid credentials")
}
user, err := ra.store.GetUserByUsername(username)
if err != nil {
return nil, errors.New("invalid credentials")
}
if user.Role != "visitor" || !user.Status {
return nil, errors.New("invalid credentials")
}
if !store.CheckPassword(user.PasswordHash, password) {
return nil, errors.New("invalid credentials")
}
if !ra.visitorAllowed(user.ID, cfg) {
return nil, errors.New("not allowed")
}
return user, nil
}
func (ra *ResourceAuth) visitorAllowed(userID uint, cfg VisitorConfig) bool {
if cfg.BindMode == "all" {
return true
}
for _, id := range cfg.UserIDs {
if id == userID {
return true
}
}
return false
}
func (ra *ResourceAuth) parseVisitorCookie(r *http.Request, resourceType string, resourceID uint) (*VisitorClaims, bool) {
cookie, err := r.Cookie(visitorCookie)
if err != nil || cookie.Value == "" {
return nil, false
}
claims, err := ra.parseVisitorToken(cookie.Value)
if err != nil {
return nil, false
}
if claims.ResourceType != resourceType || claims.ResourceID != resourceID {
return nil, false
}
return claims, true
}
func (ra *ResourceAuth) signVisitorToken(userID uint, resourceType string, resourceID uint, ttlSeconds int) (string, error) {
if ttlSeconds <= 0 {
ttlSeconds = 7200
}
claims := VisitorClaims{
UserID: userID,
ResourceType: resourceType,
ResourceID: resourceID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(ttlSeconds) * time.Second)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(ra.jwtSecret))
}
func (ra *ResourceAuth) parseVisitorToken(tokenStr string) (*VisitorClaims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &VisitorClaims{}, func(t *jwt.Token) (interface{}, error) {
return []byte(ra.jwtSecret), nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*VisitorClaims)
if !ok || !token.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}
func (ra *ResourceAuth) renderVisitorLogin(w http.ResponseWriter, r *http.Request, cfg VisitorConfig, errMsg string) {
redirect := SafeRedirectPath(r.URL.Query().Get("redirect"))
errHTML := ""
if errMsg != "" {
errHTML = fmt.Sprintf(`<p class="error">%s</p>`, html.EscapeString(errMsg))
}
page := fmt.Sprintf(`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>登录验证</title>
<style>
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f1419;color:#e7e9ea}
.card{width:100%%;max-width:380px;padding:32px 28px;background:#16181c;border:1px solid #2f3336;border-radius:16px;box-shadow:0 8px 32px rgba(0,0,0,.35)}
h1{margin:0 0 8px;font-size:22px;font-weight:600}
.sub{margin:0 0 24px;color:#71767b;font-size:14px}
label{display:block;margin-bottom:6px;font-size:13px;color:#71767b}
input{width:100%%;padding:10px 12px;margin-bottom:14px;border:1px solid #2f3336;border-radius:8px;background:#000;color:#e7e9ea;font-size:15px}
input:focus{outline:none;border-color:#1d9bf0}
button{width:100%%;padding:11px;border:none;border-radius:999px;background:#1d9bf0;color:#fff;font-size:15px;font-weight:600;cursor:pointer}
button:hover{background:#1a8cd8}
.error{color:#f4212e;font-size:13px;margin:0 0 12px}
</style>
</head>
<body>
<div class="card">
<h1>登录验证</h1>
<p class="sub">此资源已开启访客登录,请使用分配的账号访问。</p>
%s
<form method="POST" action="%s">
<input type="hidden" name="redirect" value="%s"/>
<label>用户名</label>
<input name="username" autocomplete="username" required autofocus/>
<label>密码</label>
<input name="password" type="password" autocomplete="current-password" required/>
<button type="submit">登录</button>
</form>
</div>
</body>
</html>`, errHTML, visitorLoginPath, html.EscapeString(redirect))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(page))
}
func (ra *ResourceAuth) renderVisitorLoginBlocked(w http.ResponseWriter, r *http.Request, _ VisitorConfig, retryAfter time.Duration) {
redirect := SafeRedirectPath(r.URL.Query().Get("redirect"))
minutes := int(retryAfter.Minutes())
if minutes < 1 {
minutes = 1
}
msg := fmt.Sprintf("登录尝试次数过多,请 %d 分钟后再试", minutes)
if retryAfter < time.Minute {
secs := int(retryAfter.Seconds())
if secs < 1 {
secs = 1
}
msg = fmt.Sprintf("登录尝试次数过多,请 %d 秒后再试", secs)
}
retrySecs := int(retryAfter.Seconds()) + 1
if retrySecs < 1 {
retrySecs = 1
}
w.Header().Set("Retry-After", strconv.Itoa(retrySecs))
loginURL := visitorLoginPath + "?redirect=" + url.QueryEscape(redirect)
page := fmt.Sprintf(`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>登录验证</title>
<style>
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f1419;color:#e7e9ea}
.card{width:100%%;max-width:380px;padding:32px 28px;background:#16181c;border:1px solid #2f3336;border-radius:16px;box-shadow:0 8px 32px rgba(0,0,0,.35)}
h1{margin:0 0 8px;font-size:22px;font-weight:600}
.sub{margin:0 0 16px;color:#71767b;font-size:14px}
.error{color:#f4212e;font-size:14px;margin:0 0 16px;line-height:1.5}
a{color:#1d9bf0;text-decoration:none}
</style>
</head>
<body>
<div class="card">
<h1>登录验证</h1>
<p class="sub">此资源已开启访客登录,请使用分配的账号访问。</p>
<p class="error">%s</p>
<p class="sub"><a href="%s">稍后再试</a></p>
</div>
</body>
</html>`, html.EscapeString(msg), html.EscapeString(loginURL))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(page))
}
func GetVisitorConfig(c *cache.Manager, resourceType string, resourceID uint) (VisitorConfig, []uint, bool) {
switch resourceType {
case "host":
h, ok := c.GetHost(resourceID)
if !ok {
return VisitorConfig{}, nil, false
}
cfg := visitorConfigFromHost(h)
ids, _ := c.GetResourceVisitorIDs(resourceType, resourceID)
return cfg, ids, true
case "tunnel":
t, ok := c.GetTunnel(resourceID)
if !ok {
return VisitorConfig{}, nil, false
}
cfg := visitorConfigFromTunnel(t)
ids, _ := c.GetResourceVisitorIDs(resourceType, resourceID)
return cfg, ids, true
default:
return VisitorConfig{}, nil, false
}
}
+53
View File
@@ -0,0 +1,53 @@
package auth
import (
"net/http"
"path"
"strings"
"github.com/wormhole/wormhole/internal/store"
)
func shouldLogVisitorAccess(reqPath string) bool {
if reqPath == visitorLoginPath {
return false
}
base := strings.Split(reqPath, "?")[0]
ext := strings.ToLower(path.Ext(base))
switch ext {
case ".js", ".css", ".ico", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg",
".woff", ".woff2", ".ttf", ".eot", ".map", ".wasm":
return false
}
return true
}
func (ra *ResourceAuth) logVisitorAccess(userID uint, resourceType string, resourceID uint, action string, r *http.Request) {
if ra.store == nil {
return
}
reqPath := r.URL.RequestURI()
if action == "access" && !shouldLogVisitorAccess(r.URL.Path) {
return
}
if len(reqPath) > 512 {
reqPath = reqPath[:512]
}
ua := r.UserAgent()
if len(ua) > 256 {
ua = ua[:256]
}
entry := &store.VisitorAccessLog{
UserID: userID,
ResourceType: resourceType,
ResourceID: resourceID,
Action: action,
Method: r.Method,
Path: reqPath,
IP: ExtractRemoteIP(r),
UserAgent: ua,
}
go func() {
_ = ra.store.AppendVisitorAccessLog(entry)
}()
}
+318
View File
@@ -0,0 +1,318 @@
package bridge
import (
"crypto/tls"
"fmt"
"io"
"log"
"net"
"sync"
"sync/atomic"
"time"
"github.com/xtaci/smux"
"github.com/wormhole/wormhole/internal/cache"
"github.com/wormhole/wormhole/internal/protocol"
"github.com/wormhole/wormhole/internal/store"
)
type AgentSession struct {
ClientID uint
Version string
conn net.Conn
smux *smux.Session
mu sync.Mutex
closed atomic.Bool
activeConns atomic.Int32
}
type Bridge struct {
cache *cache.Manager
store *store.Store
mu sync.RWMutex
agents map[uint]*AgentSession
listener net.Listener
}
func New(c *cache.Manager, st *store.Store) *Bridge {
return &Bridge{
cache: c,
store: st,
agents: make(map[uint]*AgentSession),
}
}
func (b *Bridge) Start(addr string, certFile, keyFile string) error {
var ln net.Listener
var err error
if certFile != "" && keyFile != "" {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return fmt.Errorf("load bridge tls cert: %w", err)
}
ln, err = tls.Listen("tcp", addr, &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
})
if err != nil {
return err
}
log.Printf("[bridge] listening on %s (TLS)", addr)
} else {
ln, err = net.Listen("tcp", addr)
if err != nil {
return err
}
log.Printf("[bridge] listening on %s (plain TCP, configure TLS cert for production)", addr)
}
b.listener = ln
go b.acceptLoop()
b.startWatchdog()
return nil
}
func (b *Bridge) Shutdown() error {
if b.listener != nil {
return b.listener.Close()
}
return nil
}
func (b *Bridge) acceptLoop() {
for {
conn, err := b.listener.Accept()
if err != nil {
return
}
go b.handleConn(conn)
}
}
func (b *Bridge) validateClient(client *store.Client) error {
if !client.Status {
return fmt.Errorf("client disabled")
}
if client.ExpireAt != nil && time.Now().After(*client.ExpireAt) {
return fmt.Errorf("client expired")
}
return nil
}
func (b *Bridge) handleConn(conn net.Conn) {
defer conn.Close()
SetTCPKeepAlive(conn)
msgType, body, err := protocol.ReadMessage(conn)
if err != nil {
log.Printf("[bridge] read register failed: %v", err)
return
}
if msgType != protocol.MsgRegister {
return
}
reg, err := protocol.ParseRegister(body)
if err != nil {
return
}
client, ok := b.cache.GetClientByKey(reg.VerifyKey)
if !ok {
_ = protocol.WriteMessage(conn, protocol.MsgRegisterAck, protocol.RegisterAckPayload{
OK: false,
Message: "invalid verify key",
})
return
}
if err := b.validateClient(client); err != nil {
_ = protocol.WriteMessage(conn, protocol.MsgRegisterAck, protocol.RegisterAckPayload{
OK: false,
Message: err.Error(),
})
return
}
if err := protocol.WriteMessage(conn, protocol.MsgRegisterAck, protocol.RegisterAckPayload{
OK: true,
ClientID: client.ID,
Message: "registered",
}); err != nil {
return
}
session, err := smux.Client(conn, smuxConfig())
if err != nil {
log.Printf("[bridge] smux client failed: %v", err)
return
}
agent := &AgentSession{
ClientID: client.ID,
Version: reg.Version,
conn: conn,
smux: session,
}
b.mu.Lock()
if old, exists := b.agents[client.ID]; exists {
old.Close()
}
b.agents[client.ID] = agent
b.mu.Unlock()
b.cache.SetClientOnline(client.ID, reg.Version)
log.Printf("[bridge] client %d online (version=%s)", client.ID, reg.Version)
b.controlLoop(agent)
b.mu.Lock()
delete(b.agents, client.ID)
b.mu.Unlock()
b.cache.SetClientOffline(client.ID)
log.Printf("[bridge] client %d offline", client.ID)
}
func (b *Bridge) controlLoop(agent *AgentSession) {
for {
stream, err := agent.smux.AcceptStream()
if err != nil {
agent.Close()
return
}
go b.handleControlStream(agent, stream)
}
}
func (b *Bridge) handleControlStream(agent *AgentSession, stream *smux.Stream) {
defer stream.Close()
msgType, body, err := protocol.ReadMessage(stream)
if err != nil {
return
}
switch msgType {
case protocol.MsgPing:
b.cache.TouchAgent(agent.ClientID)
_ = protocol.WriteMessage(stream, protocol.MsgPong, nil)
case protocol.MsgClose:
agent.Close()
}
_ = body
}
func (a *AgentSession) Close() {
if a.closed.Swap(true) {
return
}
if a.smux != nil {
_ = a.smux.Close()
}
if a.conn != nil {
_ = a.conn.Close()
}
}
func (b *Bridge) OpenStream(clientID uint, meta *protocol.LinkMeta) (net.Conn, error) {
client, err := b.store.GetClientByID(clientID)
if err != nil {
return nil, fmt.Errorf("client %d not found", clientID)
}
if err := b.validateClient(client); err != nil {
return nil, err
}
b.mu.RLock()
agent, ok := b.agents[clientID]
b.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("client %d offline", clientID)
}
if agent.closed.Load() {
return nil, fmt.Errorf("client %d session closed", clientID)
}
if client.MaxConn > 0 && int(agent.activeConns.Load()) >= client.MaxConn {
return nil, fmt.Errorf("client %d max connections reached", clientID)
}
stream, err := agent.smux.OpenStream()
if err != nil {
return nil, err
}
if err := protocol.WriteMessage(stream, protocol.MsgOpenStream, meta); err != nil {
stream.Close()
return nil, err
}
msgType, _, err := protocol.ReadMessage(stream)
if err != nil {
stream.Close()
return nil, err
}
if msgType != protocol.MsgStreamReady {
stream.Close()
return nil, fmt.Errorf("stream not ready")
}
agent.activeConns.Add(1)
var wrapped net.Conn = &trackedConn{
Conn: stream,
onClose: func() { agent.activeConns.Add(-1) },
}
if client.RateLimit > 0 {
wrapped = newRateLimitedConn(wrapped, client.RateLimit*1024)
}
return wrapped, nil
}
func (b *Bridge) IsOnline(clientID uint) bool {
b.mu.RLock()
defer b.mu.RUnlock()
_, ok := b.agents[clientID]
return ok
}
func (b *Bridge) OnlineCount() int {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.agents)
}
type trackedConn struct {
net.Conn
onClose func()
once sync.Once
}
func (c *trackedConn) Close() error {
c.once.Do(c.onClose)
return c.Conn.Close()
}
func CopyBoth(a, b net.Conn) (inlet, export int64) {
var inVal, outVal int64
done := make(chan struct{}, 2)
go func() {
n, _ := io.Copy(b, a)
atomic.AddInt64(&outVal, n)
done <- struct{}{}
}()
go func() {
n, _ := io.Copy(a, b)
atomic.AddInt64(&inVal, n)
done <- struct{}{}
}()
<-done
<-done
return atomic.LoadInt64(&inVal), atomic.LoadInt64(&outVal)
}
func CopyBothIO(a io.ReadWriteCloser, b io.ReadWriteCloser) (inlet, export int64) {
var inVal, outVal int64
done := make(chan struct{}, 2)
go func() {
n, _ := io.Copy(b, a)
atomic.AddInt64(&outVal, n)
done <- struct{}{}
}()
go func() {
n, _ := io.Copy(a, b)
atomic.AddInt64(&inVal, n)
done <- struct{}{}
}()
<-done
<-done
return atomic.LoadInt64(&inVal), atomic.LoadInt64(&outVal)
}
+46
View File
@@ -0,0 +1,46 @@
package bridge
import (
"crypto/tls"
"net"
"time"
"github.com/xtaci/smux"
)
const (
// HeartbeatInterval is the application-level ping interval (agent and server probe).
HeartbeatInterval = 30 * time.Second
// HeartbeatTimeout evicts a session when no ping/pong for this duration.
HeartbeatTimeout = 90 * time.Second
// WatchdogInterval is how often the server checks agent heartbeats.
WatchdogInterval = 15 * time.Second
// ServerProbeAfter triggers a server-initiated ping when agent has been silent.
ServerProbeAfter = 45 * time.Second
)
func SmuxConfig() *smux.Config {
return smuxConfig()
}
func smuxConfig() *smux.Config {
cfg := smux.DefaultConfig()
cfg.KeepAliveInterval = 10 * time.Second
cfg.KeepAliveTimeout = 30 * time.Second
return cfg
}
func SetTCPKeepAlive(conn net.Conn) {
for {
switch c := conn.(type) {
case *net.TCPConn:
_ = c.SetKeepAlive(true)
_ = c.SetKeepAlivePeriod(30 * time.Second)
return
case *tls.Conn:
conn = c.NetConn()
default:
return
}
}
}
+74
View File
@@ -0,0 +1,74 @@
package bridge
import (
"net"
"sync"
"time"
)
// rateLimitedConn limits read/write throughput to bytesPerSec (simple token bucket).
type rateLimitedConn struct {
net.Conn
bytesPerSec int64
mu sync.Mutex
allowance float64
lastCheck time.Time
}
func newRateLimitedConn(conn net.Conn, bytesPerSec int64) net.Conn {
if bytesPerSec <= 0 {
return conn
}
return &rateLimitedConn{
Conn: conn,
bytesPerSec: bytesPerSec,
lastCheck: time.Now(),
allowance: float64(bytesPerSec),
}
}
func (c *rateLimitedConn) wait(n int) {
if c.bytesPerSec <= 0 || n <= 0 {
return
}
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
elapsed := now.Sub(c.lastCheck).Seconds()
c.lastCheck = now
c.allowance += elapsed * float64(c.bytesPerSec)
if c.allowance > float64(c.bytesPerSec)*2 {
c.allowance = float64(c.bytesPerSec) * 2
}
for float64(n) > c.allowance {
deficit := float64(n) - c.allowance
sleep := time.Duration(deficit/float64(c.bytesPerSec)*1e9) * time.Nanosecond
if sleep < time.Millisecond {
sleep = time.Millisecond
}
c.mu.Unlock()
time.Sleep(sleep)
c.mu.Lock()
now = time.Now()
elapsed = now.Sub(c.lastCheck).Seconds()
c.lastCheck = now
c.allowance += elapsed * float64(c.bytesPerSec)
if c.allowance > float64(c.bytesPerSec)*2 {
c.allowance = float64(c.bytesPerSec) * 2
}
}
c.allowance -= float64(n)
}
func (c *rateLimitedConn) Read(b []byte) (int, error) {
n, err := c.Conn.Read(b)
if n > 0 {
c.wait(n)
}
return n, err
}
func (c *rateLimitedConn) Write(b []byte) (int, error) {
c.wait(len(b))
return c.Conn.Write(b)
}
+67
View File
@@ -0,0 +1,67 @@
package bridge
import (
"log"
"time"
"github.com/wormhole/wormhole/internal/protocol"
)
func (b *Bridge) startWatchdog() {
go func() {
ticker := time.NewTicker(WatchdogInterval)
defer ticker.Stop()
for range ticker.C {
b.runWatchdog()
}
}()
}
func (b *Bridge) runWatchdog() {
now := time.Now()
b.mu.RLock()
sessions := make([]*AgentSession, 0, len(b.agents))
for _, agent := range b.agents {
sessions = append(sessions, agent)
}
b.mu.RUnlock()
for _, agent := range sessions {
if agent.closed.Load() {
continue
}
lastPing, ok := b.cache.AgentLastPing(agent.ClientID)
if !ok {
continue
}
since := now.Sub(lastPing)
if since > HeartbeatTimeout {
log.Printf("[bridge] client %d heartbeat timeout (%v), closing", agent.ClientID, since.Round(time.Second))
agent.Close()
continue
}
if since > ServerProbeAfter {
go b.probeAgent(agent)
}
}
}
func (b *Bridge) probeAgent(agent *AgentSession) {
if agent.closed.Load() {
return
}
stream, err := agent.smux.OpenStream()
if err != nil {
return
}
defer stream.Close()
if err := protocol.WriteMessage(stream, protocol.MsgPing, nil); err != nil {
return
}
msgType, _, err := protocol.ReadMessage(stream)
if err != nil || msgType != protocol.MsgPong {
return
}
b.cache.TouchAgent(agent.ClientID)
}
+525
View File
@@ -0,0 +1,525 @@
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
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
}
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),
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
}
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.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 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) ListEnabledTunnels() []*store.Tunnel {
m.mu.RLock()
defer m.mu.RUnlock()
var out []*store.Tunnel
for _, t := range m.tunnels {
if t.Status {
out = append(out, t)
}
}
return out
}
func (m *Manager) SetTunnelRunStatus(id uint, running bool) {
m.mu.Lock()
defer m.mu.Unlock()
if t, ok := m.tunnels[id]; ok {
t.RunStatus = running
}
}
+200
View File
@@ -0,0 +1,200 @@
package config
import (
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"time"
"gopkg.in/yaml.v3"
)
const DefaultDBPath = "./data/wormhole.db"
type Config struct {
Server ServerConfig `yaml:"server" json:"server"`
Database DatabaseConfig `yaml:"database" json:"database"`
Auth AuthConfig `yaml:"auth" json:"auth"`
Metrics MetricsConfig `yaml:"metrics" json:"metrics"`
}
type ServerConfig struct {
BridgeAddr string `yaml:"bridge_addr" json:"bridge_addr"`
HTTPAddr string `yaml:"http_addr" json:"http_addr"`
HTTPProxyPort int `yaml:"http_proxy_port" json:"http_proxy_port"`
HTTPSProxyPort int `yaml:"https_proxy_port" json:"https_proxy_port"`
TLSCertFile string `yaml:"tls_cert_file" json:"tls_cert_file"`
TLSKeyFile string `yaml:"tls_key_file" json:"tls_key_file"`
}
type DatabaseConfig struct {
Path string `yaml:"path" json:"path"`
}
type AuthConfig struct {
JWTSecret string `yaml:"jwt_secret" json:"jwt_secret"`
TokenTTL time.Duration `yaml:"token_ttl" json:"token_ttl"`
LoginMaxAttempts int `yaml:"login_max_attempts" json:"login_max_attempts"`
LoginWindow time.Duration `yaml:"login_window" json:"login_window"`
LoginLockout time.Duration `yaml:"login_lockout" json:"login_lockout"`
}
type MetricsConfig struct {
FlushInterval time.Duration `yaml:"flush_interval" json:"flush_interval"`
}
// SettingsDTO is the API/UI representation of runtime settings.
type SettingsDTO struct {
Server ServerConfig `json:"server"`
Auth AuthSettings `json:"auth"`
Metrics struct {
FlushIntervalSec int `json:"flush_interval_sec"`
} `json:"metrics"`
}
type AuthSettings struct {
JWTSecret string `json:"jwt_secret,omitempty"`
TokenTTLHours int `json:"token_ttl_hours"`
LoginMaxAttempts int `json:"login_max_attempts"`
LoginWindowMin int `json:"login_window_min"`
LoginLockoutMin int `json:"login_lockout_min"`
}
func Default() *Config {
secret, _ := randomSecret(32)
return &Config{
Server: ServerConfig{
BridgeAddr: ":8528",
HTTPAddr: ":8529",
HTTPProxyPort: 8081,
HTTPSProxyPort: 8443,
},
Database: DatabaseConfig{
Path: DefaultDBPath,
},
Auth: AuthConfig{
JWTSecret: secret,
TokenTTL: 24 * time.Hour,
LoginMaxAttempts: 5,
LoginWindow: 5 * time.Minute,
LoginLockout: 15 * time.Minute,
},
Metrics: MetricsConfig{
FlushInterval: 30 * time.Second,
},
}
}
func randomSecret(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "wormhole-change-me-in-production", err
}
return hex.EncodeToString(b), nil
}
func (c *Config) Normalize() {
if c.Server.BridgeAddr == "" {
c.Server.BridgeAddr = ":8528"
}
if c.Server.HTTPAddr == "" {
c.Server.HTTPAddr = ":8529"
}
if c.Server.HTTPProxyPort == 0 {
c.Server.HTTPProxyPort = 8081
}
if c.Server.HTTPSProxyPort == 0 {
c.Server.HTTPSProxyPort = 8443
}
if c.Database.Path == "" {
c.Database.Path = DefaultDBPath
}
if c.Auth.TokenTTL == 0 {
c.Auth.TokenTTL = 24 * time.Hour
}
if c.Auth.LoginMaxAttempts == 0 {
c.Auth.LoginMaxAttempts = 5
}
if c.Auth.LoginWindow == 0 {
c.Auth.LoginWindow = 5 * time.Minute
}
if c.Auth.LoginLockout == 0 {
c.Auth.LoginLockout = 15 * time.Minute
}
if c.Auth.JWTSecret == "" {
secret, _ := randomSecret(32)
c.Auth.JWTSecret = secret
}
if c.Metrics.FlushInterval == 0 {
c.Metrics.FlushInterval = 30 * time.Second
}
}
func (c *Config) ToDTO() SettingsDTO {
sec := int(c.Metrics.FlushInterval / time.Second)
if sec < 1 {
sec = 30
}
dto := SettingsDTO{
Server: c.Server,
Auth: AuthSettings{
TokenTTLHours: int(c.Auth.TokenTTL / time.Hour),
LoginMaxAttempts: c.Auth.LoginMaxAttempts,
LoginWindowMin: int(c.Auth.LoginWindow / time.Minute),
LoginLockoutMin: int(c.Auth.LoginLockout / time.Minute),
},
}
dto.Metrics.FlushIntervalSec = sec
return dto
}
func SettingsFromDTO(d SettingsDTO, existing *Config) *Config {
cfg := Default()
if existing != nil {
*cfg = *existing
}
cfg.Server = d.Server
cfg.Auth.TokenTTL = time.Duration(d.Auth.TokenTTLHours) * time.Hour
cfg.Auth.LoginMaxAttempts = d.Auth.LoginMaxAttempts
cfg.Auth.LoginWindow = time.Duration(d.Auth.LoginWindowMin) * time.Minute
cfg.Auth.LoginLockout = time.Duration(d.Auth.LoginLockoutMin) * time.Minute
if d.Auth.JWTSecret != "" {
cfg.Auth.JWTSecret = d.Auth.JWTSecret
}
if d.Metrics.FlushIntervalSec > 0 {
cfg.Metrics.FlushInterval = time.Duration(d.Metrics.FlushIntervalSec) * time.Second
}
cfg.Normalize()
return cfg
}
func (c *Config) Summary() string {
return fmt.Sprintf(
"http_addr=%s bridge_addr=%s http_proxy_port=%d https_proxy_port=%d db=%s",
c.Server.HTTPAddr,
c.Server.BridgeAddr,
c.Server.HTTPProxyPort,
c.Server.HTTPSProxyPort,
c.Database.Path,
)
}
// Load reads config from a YAML file (tests only).
func Load(path string) (*Config, error) {
if path == "" {
return nil, fmt.Errorf("config path required")
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
cfg := Default()
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, err
}
cfg.Normalize()
return cfg, nil
}
+33
View File
@@ -0,0 +1,33 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadUsesFileValues(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "wormhole.yaml")
content := []byte(`server:
bridge_addr: ":9528"
http_addr: ":9529"
database:
path: "./tmp.db"
auth:
jwt_secret: "test-secret"
token_ttl: 1h
metrics:
flush_interval: 10s
`)
if err := os.WriteFile(cfgPath, content, 0644); err != nil {
t.Fatal(err)
}
cfg, err := Load(cfgPath)
if err != nil {
t.Fatal(err)
}
if cfg.Server.BridgeAddr != ":9528" || cfg.Server.HTTPAddr != ":9529" {
t.Fatalf("server config not applied: %+v", cfg.Server)
}
}
+120
View File
@@ -0,0 +1,120 @@
package metrics
import (
"log"
"strconv"
"strings"
"time"
"github.com/shirou/gopsutil/v3/cpu"
"github.com/shirou/gopsutil/v3/load"
"github.com/shirou/gopsutil/v3/mem"
"github.com/wormhole/wormhole/internal/bridge"
"github.com/wormhole/wormhole/internal/cache"
"github.com/wormhole/wormhole/internal/store"
)
type Collector struct {
store *store.Store
cache *cache.Manager
bridge *bridge.Bridge
stop chan struct{}
}
func NewCollector(st *store.Store, c *cache.Manager, b *bridge.Bridge) *Collector {
return &Collector{
store: st,
cache: c,
bridge: b,
stop: make(chan struct{}),
}
}
func (c *Collector) Start(flushInterval time.Duration) {
go func() {
ticker := time.NewTicker(flushInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
c.flushFlow()
case <-c.stop:
return
}
}
}()
}
func (c *Collector) Stop() {
close(c.stop)
}
func (c *Collector) flushFlow() {
deltas := c.cache.DrainFlow()
for key, delta := range deltas {
parts := strings.SplitN(key, ":", 2)
if len(parts) != 2 {
continue
}
resourceType := parts[0]
resourceID, err := strconv.ParseUint(parts[1], 10, 64)
if err != nil {
continue
}
if err := c.store.UpsertFlowStat(resourceType, uint(resourceID), delta.Inlet, delta.Export); err != nil {
log.Printf("[metrics] flush flow stat: %v", err)
}
switch resourceType {
case "client":
_ = c.store.AddClientFlow(uint(resourceID), delta.Inlet, delta.Export)
case "tunnel":
_ = c.store.AddTunnelFlow(uint(resourceID), delta.Inlet, delta.Export)
case "host":
_ = c.store.AddHostFlow(uint(resourceID), delta.Inlet, delta.Export)
}
}
}
func (c *Collector) RecordRequestIP(resourceType string, resourceID uint, visitorIP, directIP string) {
if err := c.store.RecordRequestIP(resourceType, resourceID, visitorIP, directIP); err != nil {
log.Printf("[metrics] record request ip: %v", err)
}
}
func (c *Collector) AddFlow(resourceType string, resourceID uint, clientID uint, inlet, export int64) {
c.cache.AddFlow(resourceType, resourceID, inlet, export)
if clientID > 0 {
c.cache.AddFlow("client", clientID, inlet, export)
}
}
func (c *Collector) Dashboard() (map[string]interface{}, error) {
stats, err := c.store.DashboardStats()
if err != nil {
return nil, err
}
stats["client_online"] = c.bridge.OnlineCount()
cpuPercent, _ := cpu.Percent(0, false)
memInfo, _ := mem.VirtualMemory()
loadInfo, _ := load.Avg()
var cpuVal float64
if len(cpuPercent) > 0 {
cpuVal = cpuPercent[0]
}
topClients, _ := c.store.TopClientsByFlow(10)
topTunnels, _ := c.store.TopTunnelsByFlow(10)
topHosts, _ := c.store.TopHostsByFlow(10)
topIPs, _ := c.store.TopRequestIPs(10)
stats["cpu_percent"] = cpuVal
stats["mem_percent"] = memInfo.UsedPercent
stats["load1"] = loadInfo.Load1
stats["top_clients"] = topClients
stats["top_tunnels"] = topTunnels
stats["top_hosts"] = topHosts
stats["top_ips"] = topIPs
return stats, nil
}
+99
View File
@@ -0,0 +1,99 @@
package protocol
import (
"encoding/binary"
"encoding/json"
"fmt"
"io"
)
const (
MsgRegister byte = 1
MsgRegisterAck byte = 2
MsgPing byte = 3
MsgPong byte = 4
MsgOpenStream byte = 5
MsgStreamReady byte = 6
MsgClose byte = 7
)
const (
ConnTypeTCP = "tcp"
ConnTypeUDP = "udp"
ConnTypeHTTP = "http"
)
type RegisterPayload struct {
VerifyKey string `json:"verify_key"`
Version string `json:"version"`
}
type RegisterAckPayload struct {
ClientID uint `json:"client_id"`
Message string `json:"message"`
OK bool `json:"ok"`
}
type LinkMeta struct {
ConnType string `json:"conn_type"`
Target string `json:"target"`
RemoteAddr string `json:"remote_addr"`
StreamID uint32 `json:"stream_id"`
}
func WriteMessage(w io.Writer, msgType byte, payload interface{}) error {
var body []byte
var err error
if payload != nil {
body, err = json.Marshal(payload)
if err != nil {
return err
}
}
header := make([]byte, 5)
header[0] = msgType
binary.BigEndian.PutUint32(header[1:], uint32(len(body)))
if _, err := w.Write(header); err != nil {
return err
}
if len(body) > 0 {
_, err = w.Write(body)
}
return err
}
func ReadMessage(r io.Reader) (byte, []byte, error) {
header := make([]byte, 5)
if _, err := io.ReadFull(r, header); err != nil {
return 0, nil, err
}
msgType := header[0]
length := binary.BigEndian.Uint32(header[1:])
if length == 0 {
return msgType, nil, nil
}
if length > 1<<20 {
return 0, nil, fmt.Errorf("message too large: %d", length)
}
body := make([]byte, length)
if _, err := io.ReadFull(r, body); err != nil {
return 0, nil, err
}
return msgType, body, nil
}
func ParseRegister(body []byte) (*RegisterPayload, error) {
var p RegisterPayload
if err := json.Unmarshal(body, &p); err != nil {
return nil, err
}
return &p, nil
}
func ParseLinkMeta(body []byte) (*LinkMeta, error) {
var p LinkMeta
if err := json.Unmarshal(body, &p); err != nil {
return nil, err
}
return &p, nil
}
+152
View File
@@ -0,0 +1,152 @@
package proxy
import (
"encoding/json"
"net"
"net/http"
"strings"
"github.com/wormhole/wormhole/internal/auth"
"github.com/wormhole/wormhole/internal/store"
)
const (
// IPForwardReplace 覆盖 X-Forwarded-For / X-Real-IP 为访客真实 IP。
IPForwardReplace = "replace"
// IPForwardAppend 按反向代理规范追加 X-Forwarded-ForNginx proxy_add_x_forwarded_for)。
IPForwardAppend = "append"
)
func normalizeIPForwardMode(mode string) string {
switch mode {
case IPForwardReplace, IPForwardAppend:
return mode
case "real": // 兼容旧值
return IPForwardReplace
case "proxy": // 兼容旧值
return IPForwardAppend
default:
return IPForwardReplace
}
}
func parseHeaderMap(raw string) map[string]string {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "{}" {
return nil
}
var m map[string]string
if err := json.Unmarshal([]byte(raw), &m); err != nil {
return nil
}
return m
}
func mergeHeaderMaps(base, override map[string]string) map[string]string {
out := make(map[string]string)
for k, v := range base {
out[k] = v
}
for k, v := range override {
out[k] = v
}
if len(out) == 0 {
return nil
}
return out
}
func effectiveIPMode(resourceMode, clientMode string) string {
if resourceMode != "" {
return normalizeIPForwardMode(resourceMode)
}
if clientMode != "" {
return normalizeIPForwardMode(clientMode)
}
return IPForwardReplace
}
func resolveProxyConfig(st *store.Store, clientID uint, resourceMode, resourceHeaders string) (mode string, headers map[string]string) {
client, _ := st.GetClientByID(clientID)
clientMode := ""
clientHeaders := ""
if client != nil {
clientMode = client.IPForwardMode
clientHeaders = client.CustomHeaders
}
mode = effectiveIPMode(resourceMode, clientMode)
headers = mergeHeaderMaps(parseHeaderMap(clientHeaders), parseHeaderMap(resourceHeaders))
return mode, headers
}
func hostOnly(addr string) string {
if addr == "" {
return ""
}
host, _, err := net.SplitHostPort(addr)
if err != nil {
return strings.Trim(addr, "[]")
}
return host
}
func directClientIP(r *http.Request) string {
return hostOnly(r.RemoteAddr)
}
func forwardIPForHTTP(r *http.Request, mode string) string {
mode = normalizeIPForwardMode(mode)
if mode == IPForwardAppend {
return directClientIP(r)
}
return auth.ExtractRemoteIP(r)
}
func forwardIPForTCP(clientAddr string, _ string) string {
return hostOnly(clientAddr)
}
func isForbiddenHeader(name string) bool {
switch strings.ToLower(name) {
case "connection", "transfer-encoding", "keep-alive", "proxy-connection",
"te", "trailer", "upgrade", "content-length", "host":
return true
default:
return false
}
}
func applyOutboundHeaders(req *http.Request, r *http.Request, mode string, custom map[string]string) {
mode = normalizeIPForwardMode(mode)
req.Header.Del("X-Forwarded-For")
req.Header.Del("X-Real-IP")
req.Header.Del("Forwarded")
switch mode {
case IPForwardAppend:
clientIP := directClientIP(r)
if clientIP == "" {
break
}
existing := strings.TrimSpace(r.Header.Get("X-Forwarded-For"))
xff := clientIP
if existing != "" {
xff = existing + ", " + clientIP
}
req.Header.Set("X-Forwarded-For", xff)
req.Header.Set("X-Real-IP", clientIP)
default:
visitorIP := auth.ExtractRemoteIP(r)
if visitorIP != "" {
req.Header.Set("X-Forwarded-For", visitorIP)
req.Header.Set("X-Real-IP", visitorIP)
}
}
for k, v := range custom {
if k == "" || isForbiddenHeader(k) {
continue
}
req.Header.Set(k, v)
}
}
+74
View File
@@ -0,0 +1,74 @@
package proxy
import (
"context"
"log"
"net"
"net/http"
"sync"
"time"
"github.com/wormhole/wormhole/internal/auth"
"github.com/wormhole/wormhole/internal/store"
)
type httpTunnelServer struct {
manager *Manager
tunnel *store.Tunnel
srv *http.Server
stop chan struct{}
wg sync.WaitGroup
}
func newHTTPTunnelServer(m *Manager, t *store.Tunnel) Service {
return &httpTunnelServer{
manager: m,
tunnel: t,
stop: make(chan struct{}),
}
}
func (s *httpTunnelServer) ID() uint { return s.tunnel.ID }
func (s *httpTunnelServer) Start() error {
addr := net.JoinHostPort("0.0.0.0", itoa(s.tunnel.ListenPort))
s.srv = &http.Server{
Addr: addr,
Handler: http.HandlerFunc(s.handleHTTP),
}
log.Printf("[proxy] http tunnel %d (visitor auth) listening on %s -> %s", s.tunnel.ID, addr, s.tunnel.TargetAddr)
s.wg.Add(1)
go func() {
defer s.wg.Done()
if err := s.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("[proxy] http tunnel %d error: %v", s.tunnel.ID, err)
}
}()
return nil
}
func (s *httpTunnelServer) handleHTTP(w http.ResponseWriter, r *http.Request) {
t := s.tunnel
ip := auth.ExtractRemoteIP(r)
directIP := auth.DirectRemoteIP(r)
s.manager.metrics.RecordRequestIP("tunnel", t.ID, ip, directIP)
if !s.manager.security.Allow(directIP, t.ClientID, "tunnel", t.ID) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if !s.manager.resAuth.CheckHTTP(w, r, "tunnel", t.ID) {
return
}
s.manager.proxyTunnelHTTP(w, r, t)
}
func (s *httpTunnelServer) Stop() error {
close(s.stop)
if s.srv != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = s.srv.Shutdown(ctx)
}
s.wg.Wait()
return nil
}
+341
View File
@@ -0,0 +1,341 @@
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)
host := m.cache.MatchHost(r.Host, r.URL.Path, scheme)
if host == nil {
http.Error(w, "host not found", http.StatusNotFound)
return
}
ip := auth.ExtractRemoteIP(r)
m.metrics.RecordRequestIP("host", host.ID, ip, directIP)
if !m.security.Allow(directIP, host.ClientID, "host", host.ID) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if !m.resAuth.CheckHTTP(w, r, "host", host.ID) {
return
}
if host.AutoHTTPS && scheme == "http" {
target := "https://" + r.Host + r.URL.RequestURI()
http.Redirect(w, r, target, http.StatusMovedPermanently)
return
}
m.proxyHTTP(w, r, host, "host")
})
}
func (m *Manager) proxyHTTP(w http.ResponseWriter, r *http.Request, host *store.Host, resourceType string) {
targetURL, err := url.Parse("http://" + host.TargetAddr)
if err != nil {
http.Error(w, "bad target", http.StatusBadGateway)
return
}
if !m.bridge.IsOnline(host.ClientID) {
http.Error(w, "client offline", http.StatusBadGateway)
return
}
mode, headers := resolveProxyConfig(m.store, host.ClientID, host.IPForwardMode, host.CustomHeaders)
forwardIP := forwardIPForHTTP(r, mode)
meta := &protocol.LinkMeta{
ConnType: protocol.ConnTypeHTTP,
Target: host.TargetAddr,
RemoteAddr: forwardIP,
}
stream, err := m.bridge.OpenStream(host.ClientID, meta)
if err != nil {
http.Error(w, "tunnel error", http.StatusBadGateway)
return
}
defer stream.Close()
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return stream, nil
},
}
proxy := httputil.NewSingleHostReverseProxy(targetURL)
proxy.Transport = transport
originalDirector := proxy.Director
proxy.Director = func(req *http.Request) {
originalDirector(req)
req.Host = targetURL.Host
req.URL.Host = targetURL.Host
req.URL.Scheme = targetURL.Scheme
applyOutboundHeaders(req, r, mode, headers)
}
proxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, e error) {
http.Error(rw, "proxy error", http.StatusBadGateway)
}
if isWebSocketUpgrade(r) {
proxy.ServeHTTP(w, r)
return
}
rec := &responseRecorder{ResponseWriter: w, status: 200}
proxy.ServeHTTP(rec, r)
inlet := int64(rec.written)
export := int64(r.ContentLength)
if export < 0 {
export = 0
}
m.metrics.AddFlow(resourceType, host.ID, host.ClientID, inlet, export)
}
func (m *Manager) proxyTunnelHTTP(w http.ResponseWriter, r *http.Request, t *store.Tunnel) {
host := &store.Host{
ID: t.ID,
ClientID: t.ClientID,
TargetAddr: t.TargetAddr,
IPForwardMode: t.IPForwardMode,
CustomHeaders: t.CustomHeaders,
}
m.proxyHTTP(w, r, host, "tunnel")
}
type responseRecorder struct {
http.ResponseWriter
status int
written int
}
func (r *responseRecorder) Write(b []byte) (int, error) {
n, err := r.ResponseWriter.Write(b)
r.written += n
return n, err
}
func (r *responseRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if h, ok := r.ResponseWriter.(http.Hijacker); ok {
return h.Hijack()
}
return nil, nil, fmt.Errorf("hijack not supported")
}
func (r *responseRecorder) Flush() {
if f, ok := r.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func isWebSocketUpgrade(r *http.Request) bool {
return strings.EqualFold(r.Header.Get("Upgrade"), "websocket")
}
func (m *Manager) handleTCPConnection(t *store.Tunnel, clientConn net.Conn) {
defer clientConn.Close()
ip := auth.AddrHost(clientConn.RemoteAddr())
m.metrics.RecordRequestIP("tunnel", t.ID, ip, ip)
if !m.security.Allow(ip, t.ClientID, "tunnel", t.ID) {
return
}
if !m.resAuth.CheckTCP(clientConn.RemoteAddr(), "tunnel", t.ID) {
return
}
if !m.bridge.IsOnline(t.ClientID) {
return
}
mode, _ := resolveProxyConfig(m.store, t.ClientID, t.IPForwardMode, t.CustomHeaders)
meta := &protocol.LinkMeta{
ConnType: protocol.ConnTypeTCP,
Target: t.TargetAddr,
RemoteAddr: forwardIPForTCP(clientConn.RemoteAddr().String(), mode),
}
stream, err := m.bridge.OpenStream(t.ClientID, meta)
if err != nil {
return
}
defer stream.Close()
inlet, export := bridge.CopyBoth(clientConn, stream)
m.metrics.AddFlow("tunnel", t.ID, t.ClientID, inlet, export)
}
+216
View File
@@ -0,0 +1,216 @@
package proxy
import (
"log"
"net"
"sync"
"time"
"github.com/wormhole/wormhole/internal/protocol"
"github.com/wormhole/wormhole/internal/store"
)
type tcpServer struct {
manager *Manager
tunnel *store.Tunnel
ln net.Listener
stop chan struct{}
wg sync.WaitGroup
}
func newTCPServer(m *Manager, t *store.Tunnel) Service {
return &tcpServer{
manager: m,
tunnel: t,
stop: make(chan struct{}),
}
}
func (s *tcpServer) ID() uint { return s.tunnel.ID }
func (s *tcpServer) Start() error {
addr := net.JoinHostPort("0.0.0.0", itoa(s.tunnel.ListenPort))
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
s.ln = ln
log.Printf("[proxy] tcp tunnel %d listening on %s -> %s", s.tunnel.ID, addr, s.tunnel.TargetAddr)
go s.acceptLoop()
return nil
}
func (s *tcpServer) acceptLoop() {
for {
conn, err := s.ln.Accept()
if err != nil {
select {
case <-s.stop:
return
default:
continue
}
}
s.wg.Add(1)
go func(c net.Conn) {
defer s.wg.Done()
s.manager.handleTCPConnection(s.tunnel, c)
}(conn)
}
}
func (s *tcpServer) Stop() error {
close(s.stop)
if s.ln != nil {
_ = s.ln.Close()
}
s.wg.Wait()
return nil
}
type udpSession struct {
stream net.Conn
last time.Time
}
type udpServer struct {
manager *Manager
tunnel *store.Tunnel
conn *net.UDPConn
stop chan struct{}
mu sync.Mutex
sessions map[string]*udpSession
}
func newUDPServer(m *Manager, t *store.Tunnel) Service {
return &udpServer{
manager: m,
tunnel: t,
stop: make(chan struct{}),
sessions: make(map[string]*udpSession),
}
}
func (s *udpServer) ID() uint { return s.tunnel.ID }
func (s *udpServer) Start() error {
addr := net.JoinHostPort("0.0.0.0", itoa(s.tunnel.ListenPort))
udpAddr, err := net.ResolveUDPAddr("udp", addr)
if err != nil {
return err
}
conn, err := net.ListenUDP("udp", udpAddr)
if err != nil {
return err
}
s.conn = conn
log.Printf("[proxy] udp tunnel %d listening on %s -> %s", s.tunnel.ID, addr, s.tunnel.TargetAddr)
go s.readLoop()
go s.sweeper()
return nil
}
func (s *udpServer) readLoop() {
buf := make([]byte, 65535)
for {
n, remote, err := s.conn.ReadFromUDP(buf)
if err != nil {
select {
case <-s.stop:
return
default:
continue
}
}
ip := remote.IP.String()
s.manager.metrics.RecordRequestIP("tunnel", s.tunnel.ID, ip, ip)
if !s.manager.security.Allow(ip, s.tunnel.ClientID, "tunnel", s.tunnel.ID) {
continue
}
key := remote.String()
s.mu.Lock()
sess, ok := s.sessions[key]
if !ok {
sess = s.createSession(remote)
if sess == nil {
s.mu.Unlock()
continue
}
s.sessions[key] = sess
go s.runSession(key, sess, remote)
}
sess.last = time.Now()
stream := sess.stream
s.mu.Unlock()
_, _ = stream.Write(buf[:n])
}
}
func (s *udpServer) createSession(remote *net.UDPAddr) *udpSession {
if !s.manager.bridge.IsOnline(s.tunnel.ClientID) {
return nil
}
mode, _ := resolveProxyConfig(s.manager.store, s.tunnel.ClientID, s.tunnel.IPForwardMode, s.tunnel.CustomHeaders)
meta := &protocol.LinkMeta{
ConnType: protocol.ConnTypeUDP,
Target: s.tunnel.TargetAddr,
RemoteAddr: forwardIPForTCP(remote.String(), mode),
}
stream, err := s.manager.bridge.OpenStream(s.tunnel.ClientID, meta)
if err != nil {
return nil
}
return &udpSession{stream: stream, last: time.Now()}
}
func (s *udpServer) runSession(key string, sess *udpSession, remote *net.UDPAddr) {
buf := make([]byte, 65535)
for {
n, err := sess.stream.Read(buf)
if err != nil {
break
}
_, _ = s.conn.WriteToUDP(buf[:n], remote)
inlet := int64(n)
s.manager.metrics.AddFlow("tunnel", s.tunnel.ID, s.tunnel.ClientID, inlet, 0)
}
s.mu.Lock()
delete(s.sessions, key)
s.mu.Unlock()
_ = sess.stream.Close()
}
func (s *udpServer) sweeper() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
now := time.Now()
s.mu.Lock()
for k, sess := range s.sessions {
if now.Sub(sess.last) > 120*time.Second {
_ = sess.stream.Close()
delete(s.sessions, k)
}
}
s.mu.Unlock()
case <-s.stop:
return
}
}
}
func (s *udpServer) Stop() error {
close(s.stop)
if s.conn != nil {
_ = s.conn.Close()
}
s.mu.Lock()
for _, sess := range s.sessions {
_ = sess.stream.Close()
}
s.sessions = make(map[string]*udpSession)
s.mu.Unlock()
return nil
}
+64
View File
@@ -0,0 +1,64 @@
package security
import (
"net"
"github.com/wormhole/wormhole/internal/cache"
"github.com/wormhole/wormhole/internal/store"
)
type Service struct {
cache *cache.Manager
store *store.Store
}
func NewService(c *cache.Manager, st *store.Store) *Service {
return &Service{cache: c, store: st}
}
func (s *Service) Allow(ip string, clientID uint, resourceType string, resourceID uint) bool {
scopes := []cache.Scope{
{Kind: "global", ResourceID: 0},
{Kind: "client", ResourceID: clientID},
}
if resourceType == "host" || resourceType == "tunnel" {
scopes = append(scopes, cache.Scope{Kind: resourceType, ResourceID: resourceID})
}
return s.cache.AllowIP(ip, scopes)
}
func (s *Service) BlockIP(ip, scope string, resourceID uint, remark string) error {
rule := &store.IPRule{
Scope: scope,
ResourceID: resourceID,
Type: "deny",
Pattern: ip,
PatternKind: "exact",
Priority: 100,
Remark: remark,
}
if err := s.store.CreateIPRule(rule); err != nil {
return err
}
return s.cache.ReloadIPRules(s.store)
}
func DetectPatternKind(pattern string) string {
if _, _, err := net.ParseCIDR(pattern); err == nil {
return "cidr"
}
if len(pattern) > 0 && (pattern[0] == '^' || pattern[len(pattern)-1] == '$' || containsMeta(pattern)) {
return "regex"
}
return "exact"
}
func containsMeta(s string) bool {
for _, c := range s {
switch c {
case '.', '*', '+', '?', '[', ']', '(', ')', '|', '\\':
return true
}
}
return false
}
+120
View File
@@ -0,0 +1,120 @@
package server
import (
"context"
"flag"
"io/fs"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/wormhole/wormhole/internal/api"
"github.com/wormhole/wormhole/internal/auth"
"github.com/wormhole/wormhole/internal/bridge"
"github.com/wormhole/wormhole/internal/cache"
"github.com/wormhole/wormhole/internal/config"
"github.com/wormhole/wormhole/internal/metrics"
"github.com/wormhole/wormhole/internal/proxy"
"github.com/wormhole/wormhole/internal/security"
"github.com/wormhole/wormhole/internal/store"
webdist "github.com/wormhole/wormhole/web"
)
func Run(args []string) {
fs := flag.NewFlagSet("server", flag.ExitOnError)
fs.Parse(args)
st, err := store.Open(config.DefaultDBPath)
if err != nil {
log.Fatalf("open store: %v", err)
}
cfg, err := st.LoadConfig()
if err != nil {
log.Fatalf("load settings: %v", err)
}
log.Printf("[server] settings loaded (%s)", cfg.Summary())
c := cache.NewManager()
if err := c.ReloadAll(st); err != nil {
log.Fatalf("reload cache: %v", err)
}
authSvc := auth.NewService(&cfg.Auth)
resAuth := auth.NewResourceAuth(c, st, &cfg.Auth)
sec := security.NewService(c, st)
b := bridge.New(c, st)
mc := metrics.NewCollector(st, c, b)
pm := proxy.NewManager(c, st, b, sec, resAuth, mc)
if err := b.Start(cfg.Server.BridgeAddr, cfg.Server.TLSCertFile, cfg.Server.TLSKeyFile); err != nil {
log.Fatalf("bridge start: %v", err)
}
mc.Start(cfg.Metrics.FlushInterval)
if err := pm.StartAll(); err != nil {
log.Printf("start tunnels: %v", err)
}
if err := pm.StartHTTPServer(cfg.Server.HTTPProxyPort, cfg.Server.HTTPSProxyPort, cfg.Server.TLSCertFile, cfg.Server.TLSKeyFile); err != nil {
log.Printf("start http proxy: %v", err)
}
apiServer := api.NewServer(cfg, st, c, authSvc, resAuth, sec, b, pm, mc)
router := apiServer.Router()
router.NoRoute(serveSPA())
srv := &http.Server{
Addr: cfg.Server.HTTPAddr,
Handler: router,
}
go func() {
log.Printf("[server] http api on %s", cfg.Server.HTTPAddr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("http server: %v", err)
}
}()
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
log.Println("[server] shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
mc.Stop()
if err := pm.Shutdown(ctx); err != nil {
log.Printf("[server] proxy shutdown: %v", err)
}
_ = b.Shutdown()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("[server] api shutdown: %v", err)
}
log.Println("[server] stopped")
}
func serveSPA() gin.HandlerFunc {
distFS, err := fs.Sub(webdist.Dist, "dist")
if err != nil {
return func(c *gin.Context) {
c.String(http.StatusOK, "Wormhole API running. Build web dist for UI.")
}
}
fileServer := http.FileServer(http.FS(distFS))
return func(c *gin.Context) {
path := c.Request.URL.Path
if path == "/" {
path = "/index.html"
}
if f, err := distFS.Open(path[1:]); err == nil {
_ = f.Close()
fileServer.ServeHTTP(c.Writer, c.Request)
return
}
c.Request.URL.Path = "/"
fileServer.ServeHTTP(c.Writer, c.Request)
}
}
+87
View File
@@ -0,0 +1,87 @@
package store
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
)
func hashAPIKey(key string) string {
sum := sha256.Sum256([]byte(key))
return hex.EncodeToString(sum[:])
}
func generateAPIKeyPlain() (string, string, error) {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "", "", err
}
plain := "wh_" + hex.EncodeToString(b)
prefix := plain
if len(prefix) > 12 {
prefix = prefix[:12]
}
return plain, prefix, nil
}
func (s *Store) ListAPIKeys(userID uint, isAdmin bool) ([]APIKey, error) {
var keys []APIKey
q := s.db.Order("id asc")
if !isAdmin {
q = q.Where("user_id = ?", userID)
}
return keys, q.Find(&keys).Error
}
func (s *Store) CreateAPIKey(name string, userID uint) (*APIKey, string, error) {
if name == "" {
return nil, "", errors.New("name required")
}
plain, prefix, err := generateAPIKeyPlain()
if err != nil {
return nil, "", err
}
key := &APIKey{
Name: name,
KeyPrefix: prefix,
KeyHash: hashAPIKey(plain),
UserID: userID,
Status: true,
}
if err := s.db.Create(key).Error; err != nil {
return nil, "", err
}
return key, plain, nil
}
func (s *Store) DeleteAPIKey(id uint, userID uint, isAdmin bool) error {
var key APIKey
if err := s.db.First(&key, id).Error; err != nil {
return err
}
if !isAdmin && key.UserID != userID {
return fmt.Errorf("permission denied")
}
return s.db.Delete(&APIKey{}, id).Error
}
func (s *Store) ValidateAPIKey(plain string) (*User, error) {
if plain == "" {
return nil, errors.New("empty api key")
}
hash := hashAPIKey(plain)
var key APIKey
if err := s.db.Where("key_hash = ? AND status = ?", hash, true).First(&key).Error; err != nil {
return nil, errors.New("invalid api key")
}
user, err := s.GetUserByID(key.UserID)
if err != nil {
return nil, err
}
if !user.Status {
return nil, errors.New("user disabled")
}
return user, nil
}
+145
View File
@@ -0,0 +1,145 @@
package store
import (
"errors"
"time"
)
const ExportVersion = 1
type TunnelExportItem struct {
ClientID uint `json:"client_id"`
ClientName string `json:"client_name,omitempty"`
Name string `json:"name"`
Mode string `json:"mode"`
ListenIP string `json:"listen_ip"`
ListenPort int `json:"listen_port"`
TargetAddr string `json:"target_addr"`
IPForwardMode string `json:"ip_forward_mode"`
CustomHeaders string `json:"custom_headers"`
Status bool `json:"status"`
}
type HostExportItem struct {
ClientID uint `json:"client_id"`
ClientName string `json:"client_name,omitempty"`
Host string `json:"host"`
Location string `json:"location"`
Scheme string `json:"scheme"`
TargetAddr string `json:"target_addr"`
AutoHTTPS bool `json:"auto_https"`
IPForwardMode string `json:"ip_forward_mode"`
CustomHeaders string `json:"custom_headers"`
Status bool `json:"status"`
}
type TunnelExportDoc struct {
Version int `json:"version"`
ExportedAt time.Time `json:"exported_at"`
Items []TunnelExportItem `json:"items"`
}
type HostExportDoc struct {
Version int `json:"version"`
ExportedAt time.Time `json:"exported_at"`
Items []HostExportItem `json:"items"`
}
func TunnelToExportItem(t Tunnel, clientName string) TunnelExportItem {
return TunnelExportItem{
ClientID: t.ClientID,
ClientName: clientName,
Name: t.Name,
Mode: t.Mode,
ListenIP: t.ListenIP,
ListenPort: t.ListenPort,
TargetAddr: t.TargetAddr,
IPForwardMode: t.IPForwardMode,
CustomHeaders: t.CustomHeaders,
Status: t.Status,
}
}
func HostToExportItem(h Host, clientName string) HostExportItem {
return HostExportItem{
ClientID: h.ClientID,
ClientName: clientName,
Host: h.Host,
Location: h.Location,
Scheme: h.Scheme,
TargetAddr: h.TargetAddr,
AutoHTTPS: h.AutoHTTPS,
IPForwardMode: h.IPForwardMode,
CustomHeaders: h.CustomHeaders,
Status: h.Status,
}
}
func (s *Store) FindTunnelByPort(port int) (*Tunnel, error) {
var t Tunnel
if err := s.db.Where("listen_port = ?", port).First(&t).Error; err != nil {
return nil, err
}
return &t, nil
}
func (s *Store) FindHostByRouteGlobal(host, location string) (*Host, error) {
if location == "" {
location = "/"
}
var h Host
if err := s.db.Where("host = ? AND location = ?", host, location).First(&h).Error; err != nil {
return nil, err
}
return &h, nil
}
func (s *Store) GetClientByName(name string) (*Client, error) {
var c Client
if err := s.db.Where("name = ?", name).First(&c).Error; err != nil {
return nil, err
}
return &c, nil
}
func (s *Store) FindTunnelByListen(clientID uint, listenIP string, listenPort int) (*Tunnel, error) {
if listenIP == "" {
listenIP = "0.0.0.0"
}
var t Tunnel
if err := s.db.Where("client_id = ? AND listen_ip = ? AND listen_port = ?", clientID, listenIP, listenPort).First(&t).Error; err != nil {
return nil, err
}
return &t, nil
}
func (s *Store) FindHostByRoute(clientID uint, host, location string) (*Host, error) {
if location == "" {
location = "/"
}
var h Host
if err := s.db.Where("client_id = ? AND host = ? AND location = ?", clientID, host, location).First(&h).Error; err != nil {
return nil, err
}
return &h, nil
}
func ResolveImportClientID(s *Store, clientID uint, clientName string, ownerID uint, isAdmin bool) (uint, error) {
if clientID > 0 {
if err := ClientOwnerCheck(s, clientID, ownerID, isAdmin); err != nil {
return 0, err
}
return clientID, nil
}
if clientName == "" {
return 0, errors.New("client_id or client_name required")
}
c, err := s.GetClientByName(clientName)
if err != nil {
return 0, errors.New("client not found: " + clientName)
}
if err := ClientOwnerCheck(s, c.ID, ownerID, isAdmin); err != nil {
return 0, err
}
return c.ID, nil
}
+161
View File
@@ -0,0 +1,161 @@
package store
import (
"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"`
}
+204
View File
@@ -0,0 +1,204 @@
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 (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) HostRouteConflict(host, location string, excludeHostID uint) error {
if location == "" {
location = "/"
}
inUse, err := s.HostRouteInUse(host, location, excludeHostID)
if err != nil {
return err
}
if inUse {
return fmt.Errorf("域名与路径前缀已被穿透域名占用")
}
return nil
}
func (s *Store) ListHostsPaged(clientID, ownerID uint, isAdmin bool, p PageParams) ([]Host, int64, error) {
var hosts []Host
var total int64
q := hostOwnerScope(s.db, clientID, ownerID, isAdmin)
q = applyHostSearch(q, p.Q)
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
offset, limit := NormalizePage(p)
err := q.Order("id asc").Offset(offset).Limit(limit).Find(&hosts).Error
return hosts, total, err
}
func (s *Store) TunnelPortInUse(port int, excludeID uint) (bool, error) {
if port <= 0 {
return false, fmt.Errorf("invalid port")
}
q := s.db.Model(&Tunnel{}).Where("listen_port = ?", port)
if excludeID > 0 {
q = q.Where("id <> ?", excludeID)
}
var count int64
if err := q.Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func (s *Store) HostRouteInUse(host, location string, excludeID uint) (bool, error) {
if host == "" {
return false, fmt.Errorf("host required")
}
if location == "" {
location = "/"
}
q := s.db.Model(&Host{}).Where("host = ? AND location = ?", host, location)
if excludeID > 0 {
q = q.Where("id <> ?", excludeID)
}
var count int64
if err := q.Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func (s *Store) ListTunnelsByFlow(limit, offset int) ([]Tunnel, int64, error) {
var tunnels []Tunnel
var total int64
q := s.db.Model(&Tunnel{})
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
if limit <= 0 {
limit = 10
}
err := q.Order("(inlet_flow + export_flow) desc").Offset(offset).Limit(limit).Find(&tunnels).Error
return tunnels, total, err
}
func (s *Store) ListHostsByFlow(limit, offset int) ([]Host, int64, error) {
var hosts []Host
var total int64
q := s.db.Model(&Host{})
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
if limit <= 0 {
limit = 10
}
err := q.Order("(inlet_flow + export_flow) desc").Offset(offset).Limit(limit).Find(&hosts).Error
return hosts, total, err
}
func (s *Store) ListAllRequestIPs(limit, offset int, qstr string) ([]RequestIP, int64, error) {
var items []RequestIP
var total int64
q := s.db.Model(&RequestIP{})
if qstr != "" {
like := "%" + qstr + "%"
q = q.Where("ip LIKE ? OR direct_ip LIKE ?", like, like)
}
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
if limit <= 0 {
limit = 50
}
err := q.Order("hit_count desc").Offset(offset).Limit(limit).Find(&items).Error
return items, total, err
}
func ParsePageQuery(pageStr, pageSizeStr, q string) PageParams {
page, _ := strconv.Atoi(pageStr)
pageSize, _ := strconv.Atoi(pageSizeStr)
return PageParams{Page: page, PageSize: pageSize, Q: q}
}
+71
View File
@@ -0,0 +1,71 @@
package store
import (
"encoding/json"
"errors"
"time"
"github.com/wormhole/wormhole/internal/config"
"gorm.io/gorm"
)
const systemSettingsID = 1
type SystemSettings struct {
ID uint `gorm:"primaryKey" json:"id"`
Payload string `gorm:"type:text;not null" json:"-"`
UpdatedAt time.Time `json:"updated_at"`
}
func (s *Store) ensureSystemSettings() error {
var row SystemSettings
err := s.db.First(&row, systemSettingsID).Error
if err == nil {
return nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
cfg := config.Default()
payload, err := json.Marshal(cfg)
if err != nil {
return err
}
return s.db.Create(&SystemSettings{
ID: systemSettingsID,
Payload: string(payload),
}).Error
}
func (s *Store) LoadConfig() (*config.Config, error) {
if err := s.ensureSystemSettings(); err != nil {
return nil, err
}
var row SystemSettings
if err := s.db.First(&row, systemSettingsID).Error; err != nil {
return nil, err
}
cfg := config.Default()
if err := json.Unmarshal([]byte(row.Payload), cfg); err != nil {
return nil, err
}
cfg.Database.Path = config.DefaultDBPath
cfg.Normalize()
return cfg, nil
}
func (s *Store) SaveConfig(cfg *config.Config) error {
if err := s.ensureSystemSettings(); err != nil {
return err
}
cfg.Database.Path = config.DefaultDBPath
cfg.Normalize()
payload, err := json.Marshal(cfg)
if err != nil {
return err
}
return s.db.Model(&SystemSettings{}).Where("id = ?", systemSettingsID).Updates(map[string]interface{}{
"payload": string(payload),
"updated_at": time.Now(),
}).Error
}
+515
View File
@@ -0,0 +1,515 @@
package store
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/google/uuid"
"github.com/glebarez/sqlite"
"golang.org/x/crypto/bcrypt"
"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{},
)
}
func (s *Store) seed() error {
var count int64
s.db.Model(&User{}).Count(&count)
if count > 0 {
return nil
}
hash, err := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost)
if err != nil {
return err
}
admin := User{
Username: "admin",
PasswordHash: string(hash),
Role: "admin",
Status: true,
}
return s.db.Create(&admin).Error
}
func GenerateVerifyKey() string {
return uuid.New().String()
}
func HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}
func CheckPassword(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
func (s *Store) ListClients(ownerID uint, isAdmin bool) ([]Client, error) {
var clients []Client
q := s.db.Order("id asc")
if !isAdmin {
q = q.Where("owner_user_id = ?", ownerID)
}
return clients, q.Find(&clients).Error
}
func (s *Store) GetClientByID(id uint) (*Client, error) {
var c Client
if err := s.db.First(&c, id).Error; err != nil {
return nil, err
}
return &c, nil
}
func (s *Store) GetClientByVerifyKey(key string) (*Client, error) {
var c Client
if err := s.db.Where("verify_key = ? AND status = ?", key, true).First(&c).Error; err != nil {
return nil, err
}
return &c, nil
}
func (s *Store) CreateClient(c *Client) error {
if c.VerifyKey == "" {
c.VerifyKey = GenerateVerifyKey()
}
return s.db.Create(c).Error
}
func (s *Store) UpdateClient(c *Client) error {
return s.db.Save(c).Error
}
func (s *Store) DeleteClient(id uint) error {
return s.db.Transaction(func(tx *gorm.DB) error {
var tunnelIDs, hostIDs []uint
tx.Model(&Tunnel{}).Where("client_id = ?", id).Pluck("id", &tunnelIDs)
tx.Model(&Host{}).Where("client_id = ?", id).Pluck("id", &hostIDs)
for _, tid := range tunnelIDs {
if err := tx.Where("resource_type = ? AND resource_id = ?", "tunnel", tid).Delete(&AuthPolicy{}).Error; err != nil {
return err
}
if err := tx.Where("resource_type = ? AND resource_id = ?", "tunnel", tid).Delete(&ResourceVisitor{}).Error; err != nil {
return err
}
if err := tx.Where("scope = ? AND resource_id = ?", "tunnel", tid).Delete(&IPRule{}).Error; err != nil {
return err
}
if err := tx.Where("resource_type = ? AND resource_id = ?", "tunnel", tid).Delete(&FlowStat{}).Error; err != nil {
return err
}
if err := tx.Where("resource_type = ? AND resource_id = ?", "tunnel", tid).Delete(&RequestIP{}).Error; err != nil {
return err
}
}
for _, hid := range hostIDs {
if err := tx.Where("resource_type = ? AND resource_id = ?", "host", hid).Delete(&AuthPolicy{}).Error; err != nil {
return err
}
if err := tx.Where("resource_type = ? AND resource_id = ?", "host", hid).Delete(&ResourceVisitor{}).Error; err != nil {
return err
}
if err := tx.Where("scope = ? AND resource_id = ?", "host", hid).Delete(&IPRule{}).Error; err != nil {
return err
}
if err := tx.Where("resource_type = ? AND resource_id = ?", "host", hid).Delete(&FlowStat{}).Error; err != nil {
return err
}
if err := tx.Where("resource_type = ? AND resource_id = ?", "host", hid).Delete(&RequestIP{}).Error; err != nil {
return err
}
}
if err := tx.Where("scope = ? AND resource_id = ?", "client", id).Delete(&IPRule{}).Error; err != nil {
return err
}
if err := tx.Where("client_id = ?", id).Delete(&Tunnel{}).Error; err != nil {
return err
}
if err := tx.Where("client_id = ?", id).Delete(&Host{}).Error; err != nil {
return err
}
return tx.Delete(&Client{}, id).Error
})
}
func (s *Store) ListTunnels(clientID uint, ownerID uint, isAdmin bool) ([]Tunnel, error) {
var tunnels []Tunnel
q := s.db.Order("id asc")
if clientID > 0 {
q = q.Where("client_id = ?", clientID)
} else if !isAdmin {
var ids []uint
s.db.Model(&Client{}).Where("owner_user_id = ?", ownerID).Pluck("id", &ids)
if len(ids) == 0 {
return tunnels, nil
}
q = q.Where("client_id IN ?", ids)
}
return tunnels, q.Find(&tunnels).Error
}
func (s *Store) GetTunnelByID(id uint) (*Tunnel, error) {
var t Tunnel
if err := s.db.First(&t, id).Error; err != nil {
return nil, err
}
return &t, nil
}
func (s *Store) CreateTunnel(t *Tunnel) error {
return s.db.Create(t).Error
}
func (s *Store) UpdateTunnel(t *Tunnel) error {
return s.db.Save(t).Error
}
func (s *Store) DeleteTunnel(id uint) error {
_ = s.DeleteResourceVisitors("tunnel", id)
return s.db.Delete(&Tunnel{}, id).Error
}
func (s *Store) ListHosts(clientID uint, ownerID uint, isAdmin bool) ([]Host, error) {
var hosts []Host
q := s.db.Order("id asc")
if clientID > 0 {
q = q.Where("client_id = ?", clientID)
} else if !isAdmin {
var ids []uint
s.db.Model(&Client{}).Where("owner_user_id = ?", ownerID).Pluck("id", &ids)
if len(ids) == 0 {
return hosts, nil
}
q = q.Where("client_id IN ?", ids)
}
return hosts, q.Find(&hosts).Error
}
func (s *Store) GetHostByID(id uint) (*Host, error) {
var h Host
if err := s.db.First(&h, id).Error; err != nil {
return nil, err
}
return &h, nil
}
func (s *Store) CreateHost(h *Host) error {
return s.db.Create(h).Error
}
func (s *Store) UpdateHost(h *Host) error {
return s.db.Save(h).Error
}
func (s *Store) DeleteHost(id uint) error {
_ = s.DeleteResourceVisitors("host", id)
return s.db.Delete(&Host{}, id).Error
}
func (s *Store) ListIPRules(scope string, resourceID uint) ([]IPRule, error) {
var rules []IPRule
q := s.db.Order("priority desc, id asc")
if scope != "" {
q = q.Where("scope = ?", scope)
}
if resourceID > 0 {
q = q.Where("resource_id = ?", resourceID)
}
return rules, q.Find(&rules).Error
}
func (s *Store) CreateIPRule(r *IPRule) error {
return s.db.Create(r).Error
}
func (s *Store) DeleteIPRule(id uint) error {
return s.db.Delete(&IPRule{}, id).Error
}
func (s *Store) ListAuthPolicies(resourceType string, resourceID uint) ([]AuthPolicy, error) {
var policies []AuthPolicy
q := s.db.Order("id desc")
if resourceType != "" {
q = q.Where("resource_type = ?", resourceType)
}
if resourceID > 0 {
q = q.Where("resource_id = ?", resourceID)
}
return policies, q.Find(&policies).Error
}
func (s *Store) GetAuthPolicy(id uint) (*AuthPolicy, error) {
var p AuthPolicy
if err := s.db.First(&p, id).Error; err != nil {
return nil, err
}
return &p, nil
}
func (s *Store) CreateAuthPolicy(p *AuthPolicy) error {
return s.db.Create(p).Error
}
func (s *Store) UpdateAuthPolicy(p *AuthPolicy) error {
return s.db.Save(p).Error
}
func (s *Store) DeleteAuthPolicy(id uint) error {
return s.db.Delete(&AuthPolicy{}, id).Error
}
func (s *Store) ListUsers() ([]User, error) {
var users []User
return users, s.db.Order("id asc").Find(&users).Error
}
func (s *Store) GetUserByUsername(username string) (*User, error) {
var u User
if err := s.db.Where("username = ?", username).First(&u).Error; err != nil {
return nil, err
}
return &u, nil
}
func (s *Store) GetUserByID(id uint) (*User, error) {
var u User
if err := s.db.First(&u, id).Error; err != nil {
return nil, err
}
return &u, nil
}
func (s *Store) CreateUser(u *User) error {
return s.db.Create(u).Error
}
func (s *Store) UpdateUser(u *User) error {
return s.db.Save(u).Error
}
func (s *Store) DeleteUser(id uint, currentUserID uint) error {
if id == currentUserID {
return fmt.Errorf("cannot delete yourself")
}
user, err := s.GetUserByID(id)
if err != nil {
return err
}
if user.Role == "admin" {
var adminCount int64
s.db.Model(&User{}).Where("role = ?", "admin").Count(&adminCount)
if adminCount <= 1 {
return fmt.Errorf("cannot delete the last admin")
}
}
_ = s.DeleteVisitorAccessLogs(id)
return s.db.Delete(&User{}, id).Error
}
func (s *Store) RecordRequestIP(resourceType string, resourceID uint, visitorIP, directIP string) error {
var rec RequestIP
err := s.db.Where("resource_type = ? AND resource_id = ? AND ip = ?",
resourceType, resourceID, visitorIP).First(&rec).Error
now := time.Now()
if err == gorm.ErrRecordNotFound {
rec = RequestIP{
ResourceType: resourceType,
ResourceID: resourceID,
IP: visitorIP,
DirectIP: directIP,
HitCount: 1,
LastSeenAt: now,
}
return s.db.Create(&rec).Error
}
if err != nil {
return err
}
return s.db.Model(&rec).Updates(map[string]interface{}{
"hit_count": rec.HitCount + 1,
"last_seen_at": now,
"direct_ip": directIP,
}).Error
}
func (s *Store) ListRequestIPs(resourceType string, resourceID uint, limit, offset int) ([]RequestIP, int64, error) {
var items []RequestIP
var total int64
q := s.db.Model(&RequestIP{})
if resourceType != "" {
q = q.Where("resource_type = ?", resourceType)
}
if resourceID > 0 {
q = q.Where("resource_id = ?", resourceID)
}
q.Count(&total)
err := q.Order("last_seen_at desc").Limit(limit).Offset(offset).Find(&items).Error
return items, total, err
}
func (s *Store) UpsertFlowStat(resourceType string, resourceID uint, inlet, export int64) error {
var stat FlowStat
err := s.db.Where("resource_type = ? AND resource_id = ?", resourceType, resourceID).First(&stat).Error
if err == gorm.ErrRecordNotFound {
stat = FlowStat{
ResourceType: resourceType,
ResourceID: resourceID,
InletBytes: inlet,
ExportBytes: export,
}
return s.db.Create(&stat).Error
}
if err != nil {
return err
}
return s.db.Model(&stat).Updates(map[string]interface{}{
"inlet_bytes": gorm.Expr("inlet_bytes + ?", inlet),
"export_bytes": gorm.Expr("export_bytes + ?", export),
}).Error
}
func (s *Store) AddClientFlow(id uint, inlet, export int64) error {
return s.db.Model(&Client{}).Where("id = ?", id).Updates(map[string]interface{}{
"inlet_flow": gorm.Expr("inlet_flow + ?", inlet),
"export_flow": gorm.Expr("export_flow + ?", export),
}).Error
}
func (s *Store) AddTunnelFlow(id uint, inlet, export int64) error {
return s.db.Model(&Tunnel{}).Where("id = ?", id).Updates(map[string]interface{}{
"inlet_flow": gorm.Expr("inlet_flow + ?", inlet),
"export_flow": gorm.Expr("export_flow + ?", export),
}).Error
}
func (s *Store) AddHostFlow(id uint, inlet, export int64) error {
return s.db.Model(&Host{}).Where("id = ?", id).Updates(map[string]interface{}{
"inlet_flow": gorm.Expr("inlet_flow + ?", inlet),
"export_flow": gorm.Expr("export_flow + ?", export),
}).Error
}
func (s *Store) DashboardStats() (map[string]interface{}, error) {
var clientTotal, clientOnline int64
var tunnelTotal, tunnelRunning int64
var hostTotal int64
var inletFlow, exportFlow int64
s.db.Model(&Client{}).Count(&clientTotal)
s.db.Model(&Tunnel{}).Count(&tunnelTotal)
s.db.Model(&Tunnel{}).Where("run_status = ?", true).Count(&tunnelRunning)
s.db.Model(&Host{}).Count(&hostTotal)
s.db.Model(&Client{}).Select("COALESCE(SUM(inlet_flow),0)").Scan(&inletFlow)
s.db.Model(&Client{}).Select("COALESCE(SUM(export_flow),0)").Scan(&exportFlow)
return map[string]interface{}{
"client_total": clientTotal,
"client_online": clientOnline,
"tunnel_total": tunnelTotal,
"tunnel_running": tunnelRunning,
"host_total": hostTotal,
"inlet_flow": inletFlow,
"export_flow": exportFlow,
}, nil
}
func (s *Store) TopClientsByFlow(limit int) ([]Client, error) {
var clients []Client
err := s.db.Order("(inlet_flow + export_flow) desc").Limit(limit).Find(&clients).Error
return clients, err
}
func (s *Store) TopTunnelsByFlow(limit int) ([]Tunnel, error) {
var tunnels []Tunnel
err := s.db.Order("(inlet_flow + export_flow) desc").Limit(limit).Find(&tunnels).Error
return tunnels, err
}
func (s *Store) TopHostsByFlow(limit int) ([]Host, error) {
var hosts []Host
err := s.db.Order("(inlet_flow + export_flow) desc").Limit(limit).Find(&hosts).Error
return hosts, err
}
func (s *Store) TopRequestIPs(limit int) ([]RequestIP, error) {
var items []RequestIP
err := s.db.Order("hit_count desc").Limit(limit).Find(&items).Error
return items, err
}
func ClientOwnerCheck(s *Store, clientID, userID uint, isAdmin bool) error {
if isAdmin {
return nil
}
c, err := s.GetClientByID(clientID)
if err != nil {
return err
}
if c.OwnerUserID != userID {
return fmt.Errorf("permission denied")
}
return nil
}
+88
View File
@@ -0,0 +1,88 @@
package store
import "gorm.io/gorm"
func (s *Store) ListVisitors() ([]User, error) {
var users []User
err := s.db.Where("role = ?", "visitor").Order("id asc").Find(&users).Error
return users, err
}
func (s *Store) ListResourceVisitorIDs(resourceType string, resourceID uint) ([]uint, error) {
var bindings []ResourceVisitor
err := s.db.Where("resource_type = ? AND resource_id = ?", resourceType, resourceID).
Order("user_id asc").Find(&bindings).Error
if err != nil {
return nil, err
}
ids := make([]uint, len(bindings))
for i, b := range bindings {
ids[i] = b.UserID
}
return ids, nil
}
func (s *Store) SetResourceVisitors(resourceType string, resourceID uint, userIDs []uint) error {
return s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("resource_type = ? AND resource_id = ?", resourceType, resourceID).
Delete(&ResourceVisitor{}).Error; err != nil {
return err
}
for _, uid := range userIDs {
rv := ResourceVisitor{
ResourceType: resourceType,
ResourceID: resourceID,
UserID: uid,
}
if err := tx.Create(&rv).Error; err != nil {
return err
}
}
return nil
})
}
func (s *Store) DeleteResourceVisitors(resourceType string, resourceID uint) error {
return s.db.Where("resource_type = ? AND resource_id = ?", resourceType, resourceID).
Delete(&ResourceVisitor{}).Error
}
func (s *Store) ListAllResourceVisitors() ([]ResourceVisitor, error) {
var items []ResourceVisitor
err := s.db.Order("resource_type, resource_id, user_id").Find(&items).Error
return items, err
}
const MaxVisitorAccessLogsPerUser = 200
func (s *Store) AppendVisitorAccessLog(entry *VisitorAccessLog) error {
return s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(entry).Error; err != nil {
return err
}
var keepIDs []uint
if err := tx.Model(&VisitorAccessLog{}).Where("user_id = ?", entry.UserID).
Order("id desc").Limit(MaxVisitorAccessLogsPerUser).Pluck("id", &keepIDs).Error; err != nil {
return err
}
if len(keepIDs) == 0 {
return nil
}
return tx.Where("user_id = ? AND id NOT IN ?", entry.UserID, keepIDs).
Delete(&VisitorAccessLog{}).Error
})
}
func (s *Store) ListVisitorAccessLogs(userID uint, limit int) ([]VisitorAccessLog, error) {
if limit <= 0 || limit > MaxVisitorAccessLogsPerUser {
limit = MaxVisitorAccessLogsPerUser
}
var items []VisitorAccessLog
err := s.db.Where("user_id = ?", userID).
Order("id desc").Limit(limit).Find(&items).Error
return items, err
}
func (s *Store) DeleteVisitorAccessLogs(userID uint) error {
return s.db.Where("user_id = ?", userID).Delete(&VisitorAccessLog{}).Error
}
+3
View File
@@ -0,0 +1,3 @@
package version
const Version = "0.1.0"
+113
View File
@@ -0,0 +1,113 @@
-- Wormhole schema (also applied via GORM AutoMigrate)
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
status INTEGER NOT NULL DEFAULT 1,
created_at DATETIME,
updated_at DATETIME
);
CREATE TABLE IF NOT EXISTS clients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
verify_key TEXT NOT NULL UNIQUE,
owner_user_id INTEGER NOT NULL,
rate_limit INTEGER DEFAULT 0,
max_conn INTEGER DEFAULT 0,
expire_at DATETIME,
status INTEGER NOT NULL DEFAULT 1,
inlet_flow INTEGER DEFAULT 0,
export_flow INTEGER DEFAULT 0,
created_at DATETIME,
updated_at DATETIME
);
CREATE TABLE IF NOT EXISTS tunnels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL,
name TEXT,
mode TEXT NOT NULL,
listen_ip TEXT DEFAULT '0.0.0.0',
listen_port INTEGER NOT NULL,
target_addr TEXT NOT NULL,
status INTEGER NOT NULL DEFAULT 1,
run_status INTEGER NOT NULL DEFAULT 0,
inlet_flow INTEGER DEFAULT 0,
export_flow INTEGER DEFAULT 0,
created_at DATETIME,
updated_at DATETIME
);
CREATE TABLE IF NOT EXISTS hosts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL,
host TEXT NOT NULL,
location TEXT DEFAULT '/',
scheme TEXT DEFAULT 'all',
target_addr TEXT NOT NULL,
cert_path TEXT,
key_path TEXT,
auto_https INTEGER DEFAULT 0,
status INTEGER NOT NULL DEFAULT 1,
inlet_flow INTEGER DEFAULT 0,
export_flow INTEGER DEFAULT 0,
created_at DATETIME,
updated_at DATETIME
);
CREATE TABLE IF NOT EXISTS ip_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope TEXT NOT NULL,
resource_id INTEGER DEFAULT 0,
type TEXT NOT NULL,
pattern TEXT NOT NULL,
pattern_kind TEXT NOT NULL DEFAULT 'exact',
priority INTEGER DEFAULT 0,
remark TEXT,
created_at DATETIME,
updated_at DATETIME
);
CREATE TABLE IF NOT EXISTS auth_policies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
resource_type TEXT NOT NULL,
resource_id INTEGER NOT NULL,
type TEXT NOT NULL,
password TEXT,
callback_url TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
ttl_seconds INTEGER DEFAULT 7200,
created_at DATETIME,
updated_at DATETIME
);
CREATE TABLE IF NOT EXISTS flow_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
resource_type TEXT NOT NULL,
resource_id INTEGER NOT NULL,
inlet_bytes INTEGER DEFAULT 0,
export_bytes INTEGER DEFAULT 0,
updated_at DATETIME,
UNIQUE(resource_type, resource_id)
);
CREATE TABLE IF NOT EXISTS request_ips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
resource_type TEXT NOT NULL,
resource_id INTEGER NOT NULL,
ip TEXT NOT NULL,
hit_count INTEGER DEFAULT 0,
last_seen_at DATETIME,
UNIQUE(resource_type, resource_id, ip)
);
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL,
expires_at DATETIME,
created_at DATETIME
);
+10
View File
@@ -0,0 +1,10 @@
-- Proxy headers and IP forward mode for clients, tunnels, hosts
ALTER TABLE clients ADD COLUMN ip_forward_mode TEXT DEFAULT 'real';
ALTER TABLE clients ADD COLUMN custom_headers TEXT DEFAULT '';
ALTER TABLE tunnels ADD COLUMN ip_forward_mode TEXT DEFAULT 'real';
ALTER TABLE tunnels ADD COLUMN custom_headers TEXT DEFAULT '';
ALTER TABLE hosts ADD COLUMN ip_forward_mode TEXT DEFAULT 'real';
ALTER TABLE hosts ADD COLUMN custom_headers TEXT DEFAULT '';
+7
View File
@@ -0,0 +1,7 @@
-- System settings stored in DB (also via GORM AutoMigrate)
CREATE TABLE IF NOT EXISTS system_settings (
id INTEGER PRIMARY KEY,
payload TEXT NOT NULL,
updated_at DATETIME
);
+1
View File
@@ -0,0 +1 @@
registry=https://registry.npmmirror.com
+10
View File
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>Wormhole</title>
</head>
<body>
<p>Wormhole API running. Build web UI: <code>make web</code></p>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
package web
import "embed"
//go:embed dist/*
var Dist embed.FS
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Wormhole</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1846
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "wormhole-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.7.7",
"echarts": "^5.5.1",
"element-plus": "^2.8.4",
"vue": "^3.5.11",
"vue-i18n": "^9.14.4",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.4",
"vite": "^5.4.8"
}
}
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#0f7a62"/>
<g transform="translate(4 4)" stroke="#fafafa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<ellipse cx="7.5" cy="12" rx="3.25" ry="5"/>
<ellipse cx="16.5" cy="12" rx="3.25" ry="5"/>
<path d="M10.25 8.25C11.35 9.35 12.65 9.35 13.75 8.25"/>
<path d="M10.25 15.75C11.35 14.65 12.65 14.65 13.75 15.75"/>
<circle cx="12" cy="12" r="1.5" fill="#fafafa" stroke="none"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 548 B

+15
View File
@@ -0,0 +1,15 @@
<template>
<el-config-provider :locale="epLocale">
<router-view />
</el-config-provider>
</template>
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import enLocale from 'element-plus/dist/locale/en.mjs'
const { locale } = useI18n()
const epLocale = computed(() => (locale.value === 'en' ? enLocale : zhCn))
</script>
+27
View File
@@ -0,0 +1,27 @@
import axios from 'axios'
const api = axios.create({ baseURL: '/api' })
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('token')
localStorage.removeItem('user')
if (!window.location.pathname.includes('/login')) {
window.location.href = '/login'
}
}
return Promise.reject(err)
}
)
export default api
+19
View File
@@ -0,0 +1,19 @@
<template>
<div class="app-card">
<div v-if="title || $slots.header" class="app-card__header">
<slot name="header">
<h3 class="app-card__title">{{ title }}</h3>
</slot>
</div>
<div :class="padded ? 'app-card__body--padded' : ''">
<slot />
</div>
</div>
</template>
<script setup>
defineProps({
title: { type: String, default: '' },
padded: { type: Boolean, default: false },
})
</script>
+33
View File
@@ -0,0 +1,33 @@
<template>
<svg
:width="size"
:height="size"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<!-- Wormhole: twin portals linked by a tunnel (outlined, currentColor) -->
<ellipse cx="7.5" cy="12" rx="3.25" ry="5" stroke="currentColor" stroke-width="2" />
<ellipse cx="16.5" cy="12" rx="3.25" ry="5" stroke="currentColor" stroke-width="2" />
<path
d="M10.25 8.25C11.35 9.35 12.65 9.35 13.75 8.25"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
<path
d="M10.25 15.75C11.35 14.65 12.65 14.65 13.75 15.75"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
<circle cx="12" cy="12" r="1.5" fill="currentColor" />
</svg>
</template>
<script setup>
defineProps({
size: { type: [Number, String], default: 20 },
})
</script>
+37
View File
@@ -0,0 +1,37 @@
<template>
<div ref="root" class="chart-box" />
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue'
import * as echarts from 'echarts'
const props = defineProps({
option: { type: Object, default: null },
})
const root = ref(null)
let chart
function resize() {
chart?.resize()
}
function render() {
if (!chart || !props.option) return
chart.setOption(props.option, { notMerge: true })
}
onMounted(() => {
chart = echarts.init(root.value)
render()
window.addEventListener('resize', resize)
})
onUnmounted(() => {
window.removeEventListener('resize', resize)
chart?.dispose()
})
watch(() => props.option, render, { deep: true })
</script>
+36
View File
@@ -0,0 +1,36 @@
<template>
<el-select
:model-value="modelValue"
:disabled="disabled"
filterable
:placeholder="t('clientSelect.placeholder')"
class="w-full"
@update:model-value="$emit('update:modelValue', $event)"
>
<el-option
v-for="c in clients"
:key="c.id"
:label="clientLabel(c)"
:value="c.id"
/>
</el-select>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
defineProps({
modelValue: { type: Number, default: null },
clients: { type: Array, default: () => [] },
disabled: { type: Boolean, default: false },
})
defineEmits(['update:modelValue'])
function clientLabel(c) {
const status = c.online ? t('common.online') : t('common.offline')
return t('clientSelect.label', { name: c.name, id: c.id, status })
}
</script>
+109
View File
@@ -0,0 +1,109 @@
<template>
<el-button @click="handleExport" :loading="exporting">{{ t('common.export') }}</el-button>
<el-button @click="openImport">{{ t('common.import') }}</el-button>
<input
ref="fileInput"
type="file"
accept="application/json,.json"
class="hidden-input"
@change="onFileChange"
/>
<el-dialog v-model="importVisible" :title="t('importExport.importTitle')" width="480px" destroy-on-close>
<el-form label-width="100px">
<el-form-item :label="t('importExport.importMode')">
<el-select v-model="importMode">
<el-option :label="t('importExport.upsert')" value="upsert" />
<el-option :label="t('importExport.skip')" value="skip" />
<el-option :label="t('importExport.create')" value="create" />
</el-select>
</el-form-item>
<el-form-item :label="t('importExport.pending')">
<span>{{ t('importExport.pendingCount', { n: pendingItems.length }) }}</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="importVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="importing" @click="confirmImport">{{ t('importExport.startImport') }}</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { exportResource, importResource, parseImportFile } from '@/utils/importExport'
const { t } = useI18n()
const props = defineProps({
resource: { type: String, required: true },
exportFilename: { type: String, required: true },
})
const emit = defineEmits(['done'])
const exporting = ref(false)
const importing = ref(false)
const importVisible = ref(false)
const importMode = ref('upsert')
const pendingItems = ref([])
const fileInput = ref(null)
async function handleExport() {
exporting.value = true
try {
const data = await exportResource(`/${props.resource}`, props.exportFilename)
ElMessage.success(t('importExport.exportSuccess', { n: data.items?.length ?? 0 }))
} catch (e) {
ElMessage.error(e.response?.data?.error || t('message.exportFailed'))
} finally {
exporting.value = false
}
}
function openImport() {
fileInput.value?.click()
}
async function onFileChange(e) {
const file = e.target.files?.[0]
e.target.value = ''
if (!file) return
try {
pendingItems.value = await parseImportFile(file)
importMode.value = 'upsert'
importVisible.value = true
} catch (err) {
ElMessage.error(err.message || t('importExport.parseFailed'))
}
}
async function confirmImport() {
importing.value = true
try {
const result = await importResource(`/${props.resource}`, importMode.value, pendingItems.value)
const msg = t('importExport.importDone', {
created: result.created,
updated: result.updated,
skipped: result.skipped,
failed: result.failed,
})
if (result.failed > 0) {
ElMessage.warning(msg)
if (result.errors?.length) {
console.warn('import errors:', result.errors)
}
} else {
ElMessage.success(msg)
}
importVisible.value = false
emit('done')
} catch (e) {
ElMessage.error(e.response?.data?.error || t('message.importFailed'))
} finally {
importing.value = false
}
}
</script>
+18
View File
@@ -0,0 +1,18 @@
<template>
<div class="page-header">
<div>
<h1 class="page-title">{{ title }}</h1>
<p v-if="description" class="page-desc">{{ description }}</p>
</div>
<div v-if="$slots.actions" class="page-actions">
<slot name="actions" />
</div>
</div>
</template>
<script setup>
defineProps({
title: { type: String, required: true },
description: { type: String, default: '' },
})
</script>
+104
View File
@@ -0,0 +1,104 @@
<template>
<div class="proxy-headers-fields">
<el-form-item :label="t('ipForward.ipForward')">
<el-radio-group v-model="ipMode">
<el-radio label="replace">{{ ipForwardModeLabel('replace', t) }}</el-radio>
<el-radio label="append">{{ ipForwardModeLabel('append', t) }}</el-radio>
</el-radio-group>
<div class="form-hint">{{ modeHint }}</div>
</el-form-item>
<el-form-item :label="t('ipForward.customHeaders')">
<div class="header-list">
<div v-for="(row, idx) in headerRows" :key="idx" class="header-row">
<el-input v-model="row.key" :placeholder="t('ipForward.headerName')" @input="emitHeaders" />
<el-input v-model="row.value" :placeholder="t('ipForward.headerValue')" @input="emitHeaders" />
<el-button link type="danger" @click="removeRow(idx)">{{ t('common.delete') }}</el-button>
</div>
<el-button size="small" @click="addRow">{{ t('ipForward.addHeader') }}</el-button>
</div>
</el-form-item>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ipForwardModeHint, ipForwardModeLabel, normalizeIPForwardMode } from '@/utils/ipForward'
const { t } = useI18n()
const props = defineProps({
ipForwardMode: { type: String, default: 'replace' },
customHeaders: { type: String, default: '' },
})
const emit = defineEmits(['update:ipForwardMode', 'update:customHeaders'])
const headerRows = ref([])
function parseHeaders(raw) {
if (!raw || raw === '{}') return []
try {
const obj = JSON.parse(raw)
return Object.entries(obj).map(([key, value]) => ({ key, value: String(value) }))
} catch {
return []
}
}
function serializeHeaders(rows) {
const obj = {}
for (const row of rows) {
const key = row.key?.trim()
if (!key) continue
obj[key] = row.value ?? ''
}
return Object.keys(obj).length ? JSON.stringify(obj) : ''
}
function syncFromProps() {
headerRows.value = parseHeaders(props.customHeaders)
}
watch(() => props.customHeaders, syncFromProps, { immediate: true })
const ipMode = computed({
get() {
return normalizeIPForwardMode(props.ipForwardMode)
},
set(v) {
emit('update:ipForwardMode', v)
},
})
const modeHint = computed(() => ipForwardModeHint(ipMode.value, t))
function emitHeaders() {
emit('update:customHeaders', serializeHeaders(headerRows.value))
}
function addRow() {
headerRows.value.push({ key: '', value: '' })
emitHeaders()
}
function removeRow(idx) {
headerRows.value.splice(idx, 1)
emitHeaders()
}
</script>
<style scoped>
.header-list {
width: 100%;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.header-row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: var(--space-2);
align-items: center;
}
</style>
+74
View File
@@ -0,0 +1,74 @@
<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="resource_label" :label="t('common.resource')" min-width="160" show-overflow-tooltip />
<el-table-column prop="hit_count" :label="t('dashboard.hitCount')" width="80" />
</template>
</el-table>
<div class="card-pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
@current-change="load"
/>
</div>
</el-dialog>
</template>
<script setup>
import { ref, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import api from '@/api'
import { formatBytes } from '@/utils/format'
const { t } = useI18n()
const props = defineProps({
modelValue: Boolean,
type: { type: String, required: true },
title: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue'])
const visible = ref(false)
const items = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(50)
const loading = ref(false)
const displayTitle = computed(() => props.title || t('rank.defaultTitle'))
watch(() => props.modelValue, (v) => { visible.value = v })
watch(visible, (v) => emit('update:modelValue', v))
async function load() {
loading.value = true
try {
const { data } = await api.get('/dashboard/rankings', {
params: { type: props.type, page: page.value, page_size: pageSize.value },
})
items.value = data.items || []
total.value = data.total || 0
} finally {
loading.value = false
}
}
</script>
+13
View File
@@ -0,0 +1,13 @@
<template>
<div class="stat-card">
<div class="stat-card__label">{{ label }}</div>
<div class="stat-card__value">{{ value }}</div>
</div>
</template>
<script setup>
defineProps({
label: { type: String, required: true },
value: { type: [String, Number], required: true },
})
</script>
+73
View File
@@ -0,0 +1,73 @@
<template>
<div class="visitor-auth">
<el-divider content-position="left">{{ t('visitorAuth.title') }}</el-divider>
<el-form-item :label="t('visitorAuth.enable')">
<el-switch v-model="enabled" />
</el-form-item>
<template v-if="enabled">
<el-form-item :label="t('visitorAuth.allowVisitors')">
<el-radio-group v-model="bindMode">
<el-radio value="all">{{ t('visitorAuth.allVisitors') }}</el-radio>
<el-radio value="selected">{{ t('visitorAuth.selectedVisitors') }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="bindMode === 'selected'" :label="t('visitorAuth.selectVisitors')">
<el-select v-model="visitorIds" multiple filterable :placeholder="t('visitorAuth.selectVisitors')">
<el-option v-for="v in visitors" :key="v.id" :label="v.username" :value="v.id" />
</el-select>
</el-form-item>
<el-form-item :label="t('visitorAuth.ttl')">
<el-input-number v-model="ttlSeconds" :min="300" :max="604800" :step="3600" class="w-full" />
<div class="form-hint">{{ t('visitorAuth.ttlHint') }}</div>
</el-form-item>
<el-alert
v-if="tunnelHint"
type="info"
:closable="false"
show-icon
:title="t('visitorAuth.tunnelHint')"
class="u-mb-4"
/>
</template>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps({
visitorAuthEnabled: Boolean,
visitorBindMode: { type: String, default: 'all' },
visitorTtlSeconds: { type: Number, default: 7200 },
visitorIds: { type: Array, default: () => [] },
visitors: { type: Array, default: () => [] },
tunnelHint: Boolean,
})
const emit = defineEmits([
'update:visitorAuthEnabled',
'update:visitorBindMode',
'update:visitorTtlSeconds',
'update:visitorIds',
])
const enabled = computed({
get: () => props.visitorAuthEnabled,
set: (v) => emit('update:visitorAuthEnabled', v),
})
const bindMode = computed({
get: () => props.visitorBindMode || 'all',
set: (v) => emit('update:visitorBindMode', v),
})
const ttlSeconds = computed({
get: () => props.visitorTtlSeconds || 7200,
set: (v) => emit('update:visitorTtlSeconds', v),
})
const visitorIds = computed({
get: () => props.visitorIds || [],
set: (v) => emit('update:visitorIds', v),
})
</script>
+26
View File
@@ -0,0 +1,26 @@
import { ref } from 'vue'
import { formatChartTime } from '@/utils/datetime'
const MAX_POINTS = 36
export function useMetricHistory() {
const labels = ref([])
const cpu = ref([])
const memory = ref([])
const inlet = ref([])
const exportFlow = ref([])
function trim(arr) {
return arr.length > MAX_POINTS ? arr.slice(-MAX_POINTS) : arr
}
function record(snapshot) {
labels.value = trim([...labels.value, formatChartTime()])
cpu.value = trim([...cpu.value, Number(snapshot.cpu_percent?.toFixed?.(1) ?? 0)])
memory.value = trim([...memory.value, Number(snapshot.mem_percent?.toFixed?.(1) ?? 0)])
inlet.value = trim([...inlet.value, snapshot.inlet_flow ?? 0])
exportFlow.value = trim([...exportFlow.value, snapshot.export_flow ?? 0])
}
return { labels, cpu, memory, inlet, exportFlow, record }
}
+29
View File
@@ -0,0 +1,29 @@
import { createI18n } from 'vue-i18n'
import zhCN from '@/locales/zh-CN'
import en from '@/locales/en'
const LOCALE_KEY = 'wormhole-locale'
function detectLocale() {
const saved = localStorage.getItem(LOCALE_KEY)
if (saved === 'zh-CN' || saved === 'en') return saved
const lang = navigator.language || ''
return lang.startsWith('zh') ? 'zh-CN' : 'en'
}
export const i18n = createI18n({
legacy: false,
locale: detectLocale(),
fallbackLocale: 'zh-CN',
messages: { 'zh-CN': zhCN, en },
})
export function setLocale(locale) {
i18n.global.locale.value = locale
localStorage.setItem(LOCALE_KEY, locale)
document.documentElement.lang = locale === 'zh-CN' ? 'zh-CN' : 'en'
}
setLocale(i18n.global.locale.value)
export default i18n
+136
View File
@@ -0,0 +1,136 @@
<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,
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',
'/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.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>
+305
View File
@@ -0,0 +1,305 @@
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',
tunnel: 'Tunnel',
security: 'Security',
system: 'System',
},
dashboard: 'Dashboard',
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',
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?',
},
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',
resource: 'Resource',
resourceId: 'Resource ID',
selectResource: 'Select a resource',
noResource: 'No resources available. Create one first.',
resourceRequired: 'Please select a resource',
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',
},
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',
},
}
+305
View File
@@ -0,0 +1,305 @@
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: '概览',
tunnel: '穿透',
security: '安全',
system: '系统',
},
dashboard: '仪表盘',
clients: '客户端',
tunnels: '隧道',
hosts: '域名',
ipRules: 'IP 规则',
requestIPs: '请求 IP',
users: '访客用户',
settings: '系统设置',
},
topbar: {
dashboard: '仪表盘',
clients: '客户端管理',
tunnels: '隧道管理',
hosts: '域名解析',
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: '确认删除该域名?',
},
ipRules: {
title: 'IP 规则',
description: '白名单 / 黑名单 / CIDR 访问控制',
add: '新增规则',
edit: '编辑规则',
pattern: '匹配模式',
patternPlaceholder: 'IP / CIDR / 正则',
ruleType: '类型',
allow: '白名单',
deny: '黑名单',
resourceType: '资源类型',
global: '全局',
tunnel: '隧道',
host: '域名',
resource: '关联资源',
resourceId: '资源ID',
selectResource: '请选择资源',
noResource: '暂无可用资源,请先创建',
resourceRequired: '请选择关联资源',
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: '解析文件失败',
},
clientSelect: {
placeholder: '选择客户端',
label: '{name} (#{id}) · {status}',
},
rank: {
defaultTitle: '详情',
},
message: {
loginSuccess: '登录成功',
loginFailed: '登录失败',
loginRateLimit: '登录尝试次数过多',
saveSuccess: '保存成功',
saveFailed: '保存失败',
deleteSuccess: '已删除',
exportFailed: '导出失败',
importFailed: '导入失败',
loadFailed: '加载失败',
saved: '已保存',
savedRestart: '已保存,监听端口变更需重启 wormhole server 后生效',
passwordUpdated: '管理员密码已更新',
passwordChangeFailed: '修改失败',
apiKeyCreated: '已创建,请复制保存',
blockSuccess: '已加入黑名单',
},
}
+17
View File
@@ -0,0 +1,17 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import enLocale from 'element-plus/dist/locale/en.mjs'
import 'element-plus/dist/index.css'
import '@/styles/theme.css'
import App from './App.vue'
import router from './router'
import i18n from '@/i18n'
const epLocales = { 'zh-CN': zhCn, en: enLocale }
createApp(App)
.use(ElementPlus, { locale: epLocales[i18n.global.locale.value] })
.use(i18n)
.use(router)
.mount('#app')
+48
View File
@@ -0,0 +1,48 @@
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 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: 'security/ip-rules', component: IPRules },
{ path: 'security/request-ips', component: RequestIPs },
{ path: 'users', component: Users },
{ path: 'settings', component: Settings },
],
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (to.path !== '/login' && !token) {
next('/login')
} else if (to.path === '/login' && token) {
next('/dashboard')
} else {
next()
}
})
export default router
+557
View File
@@ -0,0 +1,557 @@
:root {
--background: #f4f4f5;
--foreground: #18181b;
--card: #ffffff;
--card-foreground: #18181b;
--primary: #0f7a62;
--primary-hover: #0d6b56;
--primary-foreground: #fafafa;
--muted: #f4f4f5;
--muted-foreground: #71717a;
--border: #e4e4e7;
--input: #e4e4e7;
--destructive: #dc2626;
--success: #047857;
--warning: #b45309;
--danger: #dc2626;
--info: #a1a1aa;
--sidebar: rgba(250, 250, 250, 0.85);
--sidebar-foreground: #18181b;
--sidebar-accent: #f4f4f5;
--sidebar-border: #e4e4e7;
--sidebar-width: 15rem;
--radius: 0.5rem;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--el-color-primary: #0f7a62;
--el-color-primary-light-3: #4da08c;
--el-color-primary-light-5: #76b8a8;
--el-color-primary-light-7: #a0d0c4;
--el-color-primary-light-8: #b8dcd2;
--el-color-primary-light-9: #d0e8e1;
--el-color-primary-dark-2: #0c624e;
--el-color-success: #047857;
--el-color-warning: #b45309;
--el-color-danger: #dc2626;
--el-color-info: #a1a1aa;
--el-border-radius-base: 0.5rem;
--el-border-color: #e4e4e7;
--el-bg-color-page: #f4f4f5;
--el-text-color-primary: #18181b;
--el-text-color-regular: #3f3f46;
--el-text-color-secondary: #71717a;
--el-font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif;
}
* { box-sizing: border-box; }
html, body, #app {
margin: 0;
padding: 0;
height: 100%;
font-size: 14px;
font-family: var(--el-font-family);
background: var(--background);
color: var(--foreground);
-webkit-font-smoothing: antialiased;
}
.layout {
display: flex;
height: 100vh;
overflow: hidden;
}
.sidebar {
width: var(--sidebar-width);
flex-shrink: 0;
background: var(--sidebar);
border-right: 1px solid var(--sidebar-border);
backdrop-filter: blur(8px);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.sidebar-brand {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-5) var(--space-4);
}
.brand-icon {
width: 32px;
height: 32px;
border-radius: var(--radius);
background: var(--primary);
color: var(--primary-foreground);
display: flex;
align-items: center;
justify-content: center;
}
.brand-name {
font-size: 1.125rem;
font-weight: 600;
}
.nav-group-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted-foreground);
padding: var(--space-4) var(--space-4) var(--space-2);
}
.nav-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
margin: 0 var(--space-2);
border-radius: var(--radius);
color: var(--muted-foreground);
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
}
.nav-item:hover { background: var(--sidebar-accent); }
.nav-item.active {
background: var(--sidebar-accent);
color: var(--primary);
font-weight: 600;
}
.main-area {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.topbar {
height: 52px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--space-6);
background: var(--card);
border-bottom: 1px solid var(--border);
}
.topbar-title {
font-size: 0.875rem;
color: var(--muted-foreground);
}
.topbar-user {
display: flex;
align-items: center;
gap: var(--space-3);
font-size: 0.875rem;
color: var(--muted-foreground);
}
.main-content {
flex: 1;
overflow-y: auto;
padding: var(--space-6);
background: var(--background);
}
.page {
max-width: 1400px;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: var(--space-6);
gap: var(--space-4);
}
.page-actions {
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
flex-wrap: wrap;
}
.page-title {
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.02em;
margin: 0 0 var(--space-1);
}
.page-desc {
font-size: 0.875rem;
color: var(--muted-foreground);
margin: 0;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-4);
margin-bottom: var(--space-6);
}
.stat-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-5);
}
.stat-card__label {
font-size: 0.875rem;
color: var(--muted-foreground);
margin-bottom: var(--space-2);
}
.stat-card__value {
font-size: 1.75rem;
font-weight: 600;
}
.app-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: var(--space-4);
}
.page > .app-card:last-child,
.page > .content-grid:last-child {
margin-bottom: 0;
}
.app-card__header {
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border);
}
.app-card__title {
font-size: 0.9375rem;
font-weight: 600;
margin: 0;
}
.app-card__body--padded { padding: var(--space-5); }
.content-grid {
display: grid;
gap: var(--space-4);
margin-bottom: var(--space-6);
}
.content-grid--2-1 { grid-template-columns: 1.4fr 1fr; }
.content-grid--2 { grid-template-columns: 1fr 1fr; }
.content-grid--3 { grid-template-columns: repeat(3, 1fr); }
.card-toolbar {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-4) var(--space-5) 0;
margin-bottom: var(--space-4);
flex-wrap: wrap;
}
.card-toolbar--desc {
align-items: flex-start;
}
.card-pager {
padding: var(--space-4) var(--space-5);
display: flex;
justify-content: flex-end;
}
.card-footer-hint {
padding: 0 var(--space-5) var(--space-3);
margin: 0;
}
.card-footer-hint + .card-pager {
padding-top: var(--space-3);
}
.card-inset-block {
margin: 0 var(--space-5) var(--space-4);
padding: var(--space-3);
background: var(--muted);
border-radius: var(--radius);
font-size: 0.875rem;
}
.tabs-bar {
padding: 0 var(--space-5);
border-bottom: 1px solid var(--border);
}
.tabs-bar .el-tabs__header { margin-bottom: 0; }
.page-toolbar {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-4);
flex-wrap: wrap;
}
.inline-actions {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.card-header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.form-hint {
margin-top: 6px;
font-size: 0.75rem;
color: var(--muted-foreground);
}
.form-hint--flush { margin-top: 0; }
.u-mt-3 { margin-top: var(--space-3); }
.u-mt-4 { margin-top: var(--space-4); }
.u-mb-3 { margin-bottom: var(--space-3); }
.u-mb-4 { margin-bottom: var(--space-4); }
.u-mb-6 { margin-bottom: var(--space-6); }
.text-center { text-align: center; }
.w-full { width: 100%; }
code.inline-code {
font-size: 0.75rem;
padding: 0 var(--space-1);
background: var(--muted);
border-radius: var(--radius);
}
.section-title {
font-size: 0.875rem;
font-weight: 600;
margin-bottom: var(--space-4);
}
.section-title:not(:first-child) { margin-top: var(--space-6); }
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--background);
padding: var(--space-6);
}
.login-wrap { max-width: 400px; width: 100%; text-align: center; }
.login-brand-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background: var(--primary);
color: var(--primary-foreground);
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: var(--space-4);
}
.login-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-8);
margin-top: var(--space-6);
text-align: left;
}
.status-good { color: #047857; }
.status-warn { color: #b45309; }
.status-bad { color: #dc2626; }
.status-muted { color: var(--muted-foreground); }
.toolbar-select { width: 120px; }
.toolbar-select--country { width: 160px; }
.toolbar-select--wide { width: 280px; }
.toolbar-input { width: 200px; }
.toolbar-input--wide { width: 260px; }
.toolbar-input--grow { flex: 1; min-width: 200px; }
.table-cell-select { width: 100%; min-width: 200px; }
.el-form .el-select { width: 100%; }
.el-form .el-input-number { width: 100%; }
.app-card .el-table {
--el-table-header-bg-color: var(--muted);
font-size: 0.875rem;
}
.app-card .el-table--small .el-table__cell {
padding: var(--space-2) var(--space-3);
}
.app-card .el-table--small th.el-table__cell {
font-size: 0.75rem;
font-weight: 600;
color: var(--muted-foreground);
}
.app-card .el-table--small td.el-table__cell {
color: var(--el-text-color-regular);
}
.app-card .el-table--small .el-table__row {
height: 40px;
}
.app-card .el-table .el-button.is-link {
padding: 0;
height: auto;
}
.app-card .el-table .cell {
line-height: 1.4;
}
.table-scroll--sm { max-height: 320px; }
.table-scroll--md { max-height: 360px; }
.table-scroll--lg { max-height: 480px; }
.card-toolbar .toolbar-input--grow {
flex: 1;
min-width: 200px;
}
.card-pager .el-pagination {
font-size: 0.875rem;
}
.kv-block p {
margin: 0 0 var(--space-2);
}
.kv-block p:last-child {
margin-bottom: 0;
}
.kv-block .label {
display: inline-block;
width: 5rem;
color: var(--muted-foreground);
}
.kv-block code {
font-size: 0.8125rem;
}
.cell-stack {
display: flex;
flex-direction: column;
gap: var(--space-2);
width: 100%;
}
.meta-list {
margin: 0;
padding: 0;
list-style: none;
font-size: 0.8125rem;
color: var(--muted-foreground);
}
.meta-list li {
display: flex;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-1) 0;
}
.meta-list__mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.meta-list__ok { color: var(--el-color-success); }
.meta-list__warn { color: var(--el-color-warning); }
.chart-box {
height: 280px;
}
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: #e4e4e7 transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #e4e4e7;
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.settings-form {
max-width: 640px;
}
.card-actions {
margin-bottom: var(--space-3);
}
.hidden-input {
display: none;
}
.el-card {
border: 1px solid var(--border);
box-shadow: none !important;
}
.el-dialog {
border-radius: var(--radius);
}
@media (max-width: 1024px) {
.stat-grid { grid-template-columns: repeat(2, 1fr); }
.content-grid--2-1,
.content-grid--2,
.content-grid--3 { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
.stat-grid { grid-template-columns: 1fr; }
.main-content { padding: var(--space-4); }
.page-header { flex-direction: column; }
}
+41
View File
@@ -0,0 +1,41 @@
/** §6.16 ECharts 品牌色与默认 grid */
export const CHART_COLORS = ['#0f7a62', '#71717a', '#4da08c', '#76b8a8', '#a0d0c4']
const AXIS_LABEL = { color: '#71717a', fontSize: 12 }
const AXIS_LINE = { lineStyle: { color: '#e4e4e7' } }
const SPLIT_LINE = { lineStyle: { color: '#e4e4e7' } }
export function buildLineChartOption({ labels, series, legend = true, yAxisFormatter }) {
return {
color: CHART_COLORS,
grid: { left: 40, right: 16, top: legend ? 36 : 16, bottom: 24 },
legend: legend
? { top: 0, textStyle: { fontSize: 12, color: '#71717a' } }
: undefined,
tooltip: { trigger: 'axis' },
xAxis: {
type: 'category',
data: labels,
axisLabel: AXIS_LABEL,
axisLine: AXIS_LINE,
},
yAxis: {
type: 'value',
axisLabel: {
...AXIS_LABEL,
formatter: yAxisFormatter,
},
axisLine: AXIS_LINE,
splitLine: SPLIT_LINE,
},
series: series.map((item, index) => ({
name: item.name,
type: 'line',
smooth: true,
data: item.data,
color: CHART_COLORS[index % CHART_COLORS.length],
lineStyle: { width: 2 },
showSymbol: false,
})),
}
}
+22
View File
@@ -0,0 +1,22 @@
/** §7.7: YYYY-MM-DD HH:mm:ss */
export function formatDateTime(value) {
if (!value) return '-'
let iso
if (value instanceof Date) {
if (Number.isNaN(value.getTime())) return '-'
iso = value.toISOString()
} else {
const d = new Date(value)
if (Number.isNaN(d.getTime())) return String(value)
iso = d.toISOString()
}
return iso.replace('T', ' ').slice(0, 19)
}
/** 图表 X 轴短标签 */
export function formatChartTime(date = new Date()) {
const h = String(date.getHours()).padStart(2, '0')
const m = String(date.getMinutes()).padStart(2, '0')
const s = String(date.getSeconds()).padStart(2, '0')
return `${h}:${m}:${s}`
}
+11
View File
@@ -0,0 +1,11 @@
export function formatBytes(n) {
if (!n) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let i = 0
let val = n
while (val >= 1024 && i < units.length - 1) {
val /= 1024
i++
}
return `${val.toFixed(1)} ${units[i]}`
}
+52
View File
@@ -0,0 +1,52 @@
import api from '@/api'
export function downloadJSON(filename, data) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
export function parseImportFile(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
try {
const data = JSON.parse(reader.result)
const items = Array.isArray(data) ? data : data?.items
if (!Array.isArray(items) || items.length === 0) {
reject(new Error('文件中没有可导入的配置项'))
return
}
resolve(items)
} catch {
reject(new Error('JSON 格式无效'))
}
}
reader.onerror = () => reject(new Error('读取文件失败'))
reader.readAsText(file)
})
}
export async function exportResource(path, filename) {
const { data } = await api.get(`${path}/export`)
downloadJSON(filename, data)
return data
}
export async function importResource(path, mode, items) {
const { data } = await api.post(`${path}/import`, { mode, items })
return data
}
export function importModeLabel(mode) {
const map = {
upsert: '覆盖更新(已存在则更新)',
skip: '跳过已存在(仅导入新配置)',
create: '全部新建(冲突则跳过)',
}
return map[mode] || mode
}
+19
View File
@@ -0,0 +1,19 @@
/** @param {string} mode */
export function normalizeIPForwardMode(mode) {
if (mode === 'append' || mode === 'replace') return mode
if (mode === 'proxy') return 'append'
if (mode === 'real') return 'replace'
return 'replace'
}
/** @param {string} mode @param {(key: string) => string} t */
export function ipForwardModeLabel(mode, t) {
const key = normalizeIPForwardMode(mode)
return t(`ipForward.${key}.label`)
}
/** @param {string} mode @param {(key: string) => string} t */
export function ipForwardModeHint(mode, t) {
const key = normalizeIPForwardMode(mode)
return t(`ipForward.${key}.hint`)
}

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