Go + Vue 管理台:Agent 隧道、域名反代、IP 安全、访客验证与监控大盘。 Gitea Actions CI 多架构镜像;纯 Go SQLite、无 CGO Docker 构建。 Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -0,0 +1,10 @@
|
|||||||
|
.git/
|
||||||
|
bin/
|
||||||
|
release/
|
||||||
|
data/
|
||||||
|
**/.DS_Store
|
||||||
|
web/node_modules/
|
||||||
|
web/dist/
|
||||||
|
.gitea/
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
@@ -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/
|
||||||
|
# 若静态资源嵌入 Go:COPY --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` |
|
||||||
@@ -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
|
||||||
@@ -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 网页注册的个人 runner(docker 模式),通常需在 runner 启动参数或 `config.yaml` 里加入 socket 挂载,具体路径取决于你的安装方式。
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
# 通用 Go + Vue 项目 CI(Gitea Actions)
|
||||||
|
#
|
||||||
|
# 单 job:可选 go test + Docker 多架构构建/推送
|
||||||
|
# 约定:go.mod、Dockerfile(详见 .gitea/README.md「Dockerfile 要求」);前端(可选)在 web/
|
||||||
|
#
|
||||||
|
# 前置:act_runner(ubuntu-latest + docker.sock)、Secret REGISTRY_TOKEN
|
||||||
|
# Variables:REGISTRY、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
|
||||||
@@ -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/
|
||||||
@@ -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 Hub;CI 默认 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"]
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
# Wormhole
|
||||||
|
|
||||||
|
内网穿透 + 反向代理网关,带 Web 管理台。控制面自研(Go + SQLite),数据面支持 HTTP/HTTPS 域名反代、TCP/UDP 隧道与 Agent 长连接。
|
||||||
|
|
||||||
|
|
||||||
|
服务端与 Agent 为**同一应用**,配置均在 Web 管理台完成,无需手写配置文件。管理台支持**简体中文、英文**两种界面语言,可在顶栏随时切换。
|
||||||
|
|
||||||
|
## 界面预览
|
||||||
|
|
||||||
|
以下为 Web 管理台主要页面(点击可查看原图)。
|
||||||
|
|
||||||
|
| 登录 | 监控大盘 |
|
||||||
|
|:---:|:---:|
|
||||||
|
|  |  |
|
||||||
|
| 默认密码 `admin`,首次登录后请修改 | 流量统计、请求 IP 与资源概览 |
|
||||||
|
|
||||||
|
| 客户端 | 隧道 |
|
||||||
|
|:---:|:---:|
|
||||||
|
|  |  |
|
||||||
|
| Agent 注册、在线状态与 verify_key | TCP/UDP 端口映射、启停与导入导出 |
|
||||||
|
|
||||||
|
| 域名反代 | 访客验证 |
|
||||||
|
|:---:|:---:|
|
||||||
|
|  |  |
|
||||||
|
| HTTP/HTTPS Host 路由、TLS 与请求头 | Basic / 表单 / 回调,资源级访问控制 |
|
||||||
|
|
||||||
|
| IP 规则 | 请求 IP |
|
||||||
|
|:---:|:---:|
|
||||||
|
|  |  |
|
||||||
|
| 精确 / CIDR / 正则白黑名单 | 访客 IP 监控、一键拉黑 |
|
||||||
|
|
||||||
|
| 访客用户 | 系统设置 |
|
||||||
|
|:---:|:---:|
|
||||||
|
|  |  |
|
||||||
|
| 受保护资源的访客账号管理 | 端口、认证、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) ←── 内网 Agent(smux 长连接)
|
||||||
|
↑
|
||||||
|
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 中重建 Agent;Agent 需 `--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)。
|
||||||
@@ -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
|
||||||
|
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 57 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
# 项目级 Go 工具链环境(go mod / go build 会读取)
|
||||||
|
GOPROXY=https://goproxy.cn,direct
|
||||||
|
GOSUMDB=sum.golang.google.cn
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/xtaci/smux"
|
||||||
|
"github.com/wormhole/wormhole/internal/bridge"
|
||||||
|
"github.com/wormhole/wormhole/internal/protocol"
|
||||||
|
"github.com/wormhole/wormhole/internal/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Run(args []string) {
|
||||||
|
fs := flag.NewFlagSet("agent", flag.ExitOnError)
|
||||||
|
serverAddr := fs.String("server", "127.0.0.1:8528", "wormhole server bridge address")
|
||||||
|
verifyKey := fs.String("key", "", "client verify key")
|
||||||
|
useTLS := fs.Bool("tls", false, "use TLS for bridge connection")
|
||||||
|
fs.Parse(args)
|
||||||
|
|
||||||
|
if *verifyKey == "" {
|
||||||
|
log.Fatal("verify key required: -key")
|
||||||
|
}
|
||||||
|
|
||||||
|
sig := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
backoff := time.Second
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-sig:
|
||||||
|
log.Println("[agent] shutting down")
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdown, hadSession := runSession(*serverAddr, *verifyKey, *useTLS, sig)
|
||||||
|
if shutdown {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hadSession {
|
||||||
|
backoff = time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[agent] reconnecting in %v...", backoff)
|
||||||
|
select {
|
||||||
|
case <-sig:
|
||||||
|
log.Println("[agent] shutting down")
|
||||||
|
return
|
||||||
|
case <-time.After(backoff):
|
||||||
|
}
|
||||||
|
if backoff < 30*time.Second {
|
||||||
|
backoff *= 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dialBridge(addr string, useTLS bool) (net.Conn, error) {
|
||||||
|
var conn net.Conn
|
||||||
|
var err error
|
||||||
|
if useTLS {
|
||||||
|
conn, err = tls.Dial("tcp", addr, &tls.Config{
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
InsecureSkipVerify: true,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
conn, err = net.Dial("tcp", addr)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
bridge.SetTCPKeepAlive(conn)
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runSession connects and blocks until disconnect.
|
||||||
|
// Returns shutdown=true when SIGINT/SIGTERM received; hadSession=true if registration succeeded.
|
||||||
|
func runSession(serverAddr, verifyKey string, useTLS bool, sig chan os.Signal) (shutdown, hadSession bool) {
|
||||||
|
conn, err := dialBridge(serverAddr, useTLS)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[agent] dial server: %v", err)
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
if err := protocol.WriteMessage(conn, protocol.MsgRegister, protocol.RegisterPayload{
|
||||||
|
VerifyKey: verifyKey,
|
||||||
|
Version: version.Version,
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("[agent] register: %v", err)
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
msgType, body, err := protocol.ReadMessage(conn)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[agent] register ack: %v", err)
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
if msgType != protocol.MsgRegisterAck {
|
||||||
|
log.Printf("[agent] unexpected register response")
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
var ack protocol.RegisterAckPayload
|
||||||
|
if len(body) > 0 {
|
||||||
|
if err := json.Unmarshal(body, &ack); err != nil {
|
||||||
|
log.Printf("[agent] parse ack: %v", err)
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ack.OK {
|
||||||
|
log.Printf("[agent] register failed: %s", ack.Message)
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
log.Printf("[agent] registered as client %d", ack.ClientID)
|
||||||
|
hadSession = true
|
||||||
|
|
||||||
|
session, err := smux.Server(conn, bridge.SmuxConfig())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[agent] smux: %v", err)
|
||||||
|
return false, hadSession
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
sessionDead := make(chan string, 1)
|
||||||
|
go func() {
|
||||||
|
reason := heartbeat(session)
|
||||||
|
sessionDead <- reason
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
stream, err := session.AcceptStream()
|
||||||
|
if err != nil {
|
||||||
|
select {
|
||||||
|
case sessionDead <- "accept stream: " + err.Error():
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go handleStream(stream)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-sig:
|
||||||
|
return true, hadSession
|
||||||
|
case reason := <-sessionDead:
|
||||||
|
log.Printf("[agent] session closed (%s)", reason)
|
||||||
|
return false, hadSession
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const heartbeatMaxFailures = 3
|
||||||
|
|
||||||
|
func heartbeat(session *smux.Session) string {
|
||||||
|
ticker := time.NewTicker(bridge.HeartbeatInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
failures := 0
|
||||||
|
for range ticker.C {
|
||||||
|
if err := pingOnce(session); err != nil {
|
||||||
|
failures++
|
||||||
|
log.Printf("[agent] heartbeat failed (%d/%d): %v", failures, heartbeatMaxFailures, err)
|
||||||
|
if failures >= heartbeatMaxFailures {
|
||||||
|
return "heartbeat timeout"
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
failures = 0
|
||||||
|
}
|
||||||
|
return "heartbeat stopped"
|
||||||
|
}
|
||||||
|
|
||||||
|
func pingOnce(session *smux.Session) error {
|
||||||
|
stream, err := session.OpenStream()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stream.Close()
|
||||||
|
if err := protocol.WriteMessage(stream, protocol.MsgPing, nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
msgType, _, err := protocol.ReadMessage(stream)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if msgType != protocol.MsgPong {
|
||||||
|
return fmt.Errorf("unexpected response type %d", msgType)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleStream(stream *smux.Stream) {
|
||||||
|
defer stream.Close()
|
||||||
|
msgType, body, err := protocol.ReadMessage(stream)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch msgType {
|
||||||
|
case protocol.MsgPing:
|
||||||
|
_ = protocol.WriteMessage(stream, protocol.MsgPong, nil)
|
||||||
|
return
|
||||||
|
case protocol.MsgOpenStream:
|
||||||
|
handleDataStream(stream, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDataStream(stream *smux.Stream, body []byte) {
|
||||||
|
meta, err := protocol.ParseLinkMeta(body)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target, err := net.Dial("tcp", meta.Target)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer target.Close()
|
||||||
|
_ = protocol.WriteMessage(stream, protocol.MsgStreamReady, nil)
|
||||||
|
go func() {
|
||||||
|
_, _ = io.Copy(target, stream)
|
||||||
|
}()
|
||||||
|
_, _ = io.Copy(stream, target)
|
||||||
|
}
|
||||||
@@ -0,0 +1,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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/wormhole/wormhole/internal/auth"
|
||||||
|
"github.com/wormhole/wormhole/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type importResult struct {
|
||||||
|
Created int `json:"created"`
|
||||||
|
Updated int `json:"updated"`
|
||||||
|
Skipped int `json:"skipped"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
Errors []string `json:"errors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tunnelImportRequest struct {
|
||||||
|
Mode string `json:"mode"` // create, upsert, skip
|
||||||
|
Items []store.TunnelExportItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type hostImportRequest struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Items []store.HostExportItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) exportTunnels(c *gin.Context) {
|
||||||
|
claims := getClaims(c)
|
||||||
|
clientID, _ := strconv.ParseUint(c.Query("client_id"), 10, 64)
|
||||||
|
tunnels, err := s.store.ListTunnels(uint(clientID), claims.UserID, auth.IsAdmin(claims.Role))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clientNames := tunnelClientNames(s.store, tunnels)
|
||||||
|
items := make([]store.TunnelExportItem, 0, len(tunnels))
|
||||||
|
for _, t := range tunnels {
|
||||||
|
items = append(items, store.TunnelToExportItem(t, clientNames[t.ClientID]))
|
||||||
|
}
|
||||||
|
doc := store.TunnelExportDoc{
|
||||||
|
Version: store.ExportVersion,
|
||||||
|
ExportedAt: time.Now().UTC(),
|
||||||
|
Items: items,
|
||||||
|
}
|
||||||
|
c.Header("Content-Disposition", `attachment; filename="wormhole-tunnels.json"`)
|
||||||
|
c.JSON(http.StatusOK, doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) importTunnels(c *gin.Context) {
|
||||||
|
claims := getClaims(c)
|
||||||
|
var req tunnelImportRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Items) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "items is empty"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mode := req.Mode
|
||||||
|
if mode == "" {
|
||||||
|
mode = "upsert"
|
||||||
|
}
|
||||||
|
result := importResult{}
|
||||||
|
for i, item := range req.Items {
|
||||||
|
clientID, err := store.ResolveImportClientID(s.store, item.ClientID, item.ClientName, claims.UserID, auth.IsAdmin(claims.Role))
|
||||||
|
if err != nil {
|
||||||
|
result.Failed++
|
||||||
|
result.Errors = append(result.Errors, formatImportError("tunnel", i+1, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t := tunnelFromImportItem(item, clientID)
|
||||||
|
if err := normalizeTunnel(&t); err != nil {
|
||||||
|
result.Failed++
|
||||||
|
result.Errors = append(result.Errors, formatImportError("tunnel", i+1, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
existing, err := s.store.FindTunnelByPort(t.ListenPort)
|
||||||
|
switch mode {
|
||||||
|
case "create":
|
||||||
|
if err == nil {
|
||||||
|
result.Skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.store.CreateTunnel(&t); err != nil {
|
||||||
|
result.Failed++
|
||||||
|
result.Errors = append(result.Errors, formatImportError("tunnel", i+1, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.cache.UpsertTunnel(&t)
|
||||||
|
if t.Status {
|
||||||
|
_ = s.proxy.StartTunnel(t.ID)
|
||||||
|
}
|
||||||
|
result.Created++
|
||||||
|
case "skip":
|
||||||
|
if err == nil {
|
||||||
|
result.Skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.store.CreateTunnel(&t); err != nil {
|
||||||
|
result.Failed++
|
||||||
|
result.Errors = append(result.Errors, formatImportError("tunnel", i+1, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.cache.UpsertTunnel(&t)
|
||||||
|
if t.Status {
|
||||||
|
_ = s.proxy.StartTunnel(t.ID)
|
||||||
|
}
|
||||||
|
result.Created++
|
||||||
|
default: // upsert
|
||||||
|
if err != nil {
|
||||||
|
if err := s.store.CreateTunnel(&t); err != nil {
|
||||||
|
result.Failed++
|
||||||
|
result.Errors = append(result.Errors, formatImportError("tunnel", i+1, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.cache.UpsertTunnel(&t)
|
||||||
|
if t.Status {
|
||||||
|
_ = s.proxy.StartTunnel(t.ID)
|
||||||
|
}
|
||||||
|
result.Created++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wasRunning := existing.RunStatus
|
||||||
|
if wasRunning {
|
||||||
|
_ = s.proxy.StopTunnel(existing.ID)
|
||||||
|
}
|
||||||
|
t.ID = existing.ID
|
||||||
|
t.ClientID = existing.ClientID
|
||||||
|
t.RunStatus = false
|
||||||
|
if err := s.store.UpdateTunnel(&t); err != nil {
|
||||||
|
result.Failed++
|
||||||
|
result.Errors = append(result.Errors, formatImportError("tunnel", i+1, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.cache.UpsertTunnel(&t)
|
||||||
|
if t.Status {
|
||||||
|
_ = s.proxy.StartTunnel(t.ID)
|
||||||
|
}
|
||||||
|
result.Updated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) exportHosts(c *gin.Context) {
|
||||||
|
claims := getClaims(c)
|
||||||
|
clientID, _ := strconv.ParseUint(c.Query("client_id"), 10, 64)
|
||||||
|
hosts, err := s.store.ListHosts(uint(clientID), claims.UserID, auth.IsAdmin(claims.Role))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clientNames := hostClientNames(s.store, hosts)
|
||||||
|
items := make([]store.HostExportItem, 0, len(hosts))
|
||||||
|
for _, h := range hosts {
|
||||||
|
items = append(items, store.HostToExportItem(h, clientNames[h.ClientID]))
|
||||||
|
}
|
||||||
|
doc := store.HostExportDoc{
|
||||||
|
Version: store.ExportVersion,
|
||||||
|
ExportedAt: time.Now().UTC(),
|
||||||
|
Items: items,
|
||||||
|
}
|
||||||
|
c.Header("Content-Disposition", `attachment; filename="wormhole-hosts.json"`)
|
||||||
|
c.JSON(http.StatusOK, doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) importHosts(c *gin.Context) {
|
||||||
|
claims := getClaims(c)
|
||||||
|
var req hostImportRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Items) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "items is empty"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mode := req.Mode
|
||||||
|
if mode == "" {
|
||||||
|
mode = "upsert"
|
||||||
|
}
|
||||||
|
result := importResult{}
|
||||||
|
for i, item := range req.Items {
|
||||||
|
clientID, err := store.ResolveImportClientID(s.store, item.ClientID, item.ClientName, claims.UserID, auth.IsAdmin(claims.Role))
|
||||||
|
if err != nil {
|
||||||
|
result.Failed++
|
||||||
|
result.Errors = append(result.Errors, formatImportError("host", i+1, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h := hostFromImportItem(item, clientID)
|
||||||
|
if err := normalizeHost(&h); err != nil {
|
||||||
|
result.Failed++
|
||||||
|
result.Errors = append(result.Errors, formatImportError("host", i+1, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
existing, err := s.store.FindHostByRouteGlobal(h.Host, h.Location)
|
||||||
|
switch mode {
|
||||||
|
case "create":
|
||||||
|
if err == nil {
|
||||||
|
result.Skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.store.CreateHost(&h); err != nil {
|
||||||
|
result.Failed++
|
||||||
|
result.Errors = append(result.Errors, formatImportError("host", i+1, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.cache.UpsertHost(&h)
|
||||||
|
result.Created++
|
||||||
|
case "skip":
|
||||||
|
if err == nil {
|
||||||
|
result.Skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.store.CreateHost(&h); err != nil {
|
||||||
|
result.Failed++
|
||||||
|
result.Errors = append(result.Errors, formatImportError("host", i+1, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.cache.UpsertHost(&h)
|
||||||
|
result.Created++
|
||||||
|
default:
|
||||||
|
if err != nil {
|
||||||
|
if err := s.store.CreateHost(&h); err != nil {
|
||||||
|
result.Failed++
|
||||||
|
result.Errors = append(result.Errors, formatImportError("host", i+1, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.cache.UpsertHost(&h)
|
||||||
|
result.Created++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h.ID = existing.ID
|
||||||
|
h.ClientID = existing.ClientID
|
||||||
|
if err := s.store.UpdateHost(&h); err != nil {
|
||||||
|
result.Failed++
|
||||||
|
result.Errors = append(result.Errors, formatImportError("host", i+1, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.cache.UpsertHost(&h)
|
||||||
|
result.Updated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tunnelFromImportItem(item store.TunnelExportItem, clientID uint) store.Tunnel {
|
||||||
|
return store.Tunnel{
|
||||||
|
ClientID: clientID,
|
||||||
|
Name: item.Name,
|
||||||
|
Mode: item.Mode,
|
||||||
|
ListenIP: item.ListenIP,
|
||||||
|
ListenPort: item.ListenPort,
|
||||||
|
TargetAddr: item.TargetAddr,
|
||||||
|
IPForwardMode: item.IPForwardMode,
|
||||||
|
CustomHeaders: item.CustomHeaders,
|
||||||
|
Status: item.Status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostFromImportItem(item store.HostExportItem, clientID uint) store.Host {
|
||||||
|
return store.Host{
|
||||||
|
ClientID: clientID,
|
||||||
|
Host: item.Host,
|
||||||
|
Location: item.Location,
|
||||||
|
Scheme: item.Scheme,
|
||||||
|
TargetAddr: item.TargetAddr,
|
||||||
|
AutoHTTPS: item.AutoHTTPS,
|
||||||
|
IPForwardMode: item.IPForwardMode,
|
||||||
|
CustomHeaders: item.CustomHeaders,
|
||||||
|
Status: item.Status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTunnel(t *store.Tunnel) error {
|
||||||
|
if t.ListenIP == "" {
|
||||||
|
t.ListenIP = "0.0.0.0"
|
||||||
|
}
|
||||||
|
if t.Mode == "" {
|
||||||
|
t.Mode = "tcp"
|
||||||
|
}
|
||||||
|
if t.ListenPort <= 0 || t.TargetAddr == "" {
|
||||||
|
return errImportField("listen_port and target_addr required")
|
||||||
|
}
|
||||||
|
if t.IPForwardMode == "" {
|
||||||
|
t.IPForwardMode = "replace"
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeHost(h *store.Host) error {
|
||||||
|
if h.Host == "" || h.TargetAddr == "" {
|
||||||
|
return errImportField("host and target_addr required")
|
||||||
|
}
|
||||||
|
if h.Location == "" {
|
||||||
|
h.Location = "/"
|
||||||
|
}
|
||||||
|
if h.Scheme == "" {
|
||||||
|
h.Scheme = "all"
|
||||||
|
}
|
||||||
|
if h.IPForwardMode == "" {
|
||||||
|
h.IPForwardMode = "replace"
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type importFieldError string
|
||||||
|
|
||||||
|
func errImportField(msg string) error { return importFieldError(msg) }
|
||||||
|
|
||||||
|
func (e importFieldError) Error() string { return string(e) }
|
||||||
|
|
||||||
|
func formatImportError(kind string, index int, err error) string {
|
||||||
|
return kind + " #" + strconv.Itoa(index) + ": " + err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func tunnelClientNames(st *store.Store, tunnels []store.Tunnel) map[uint]string {
|
||||||
|
ids := make(map[uint]struct{})
|
||||||
|
for _, t := range tunnels {
|
||||||
|
ids[t.ClientID] = struct{}{}
|
||||||
|
}
|
||||||
|
names := make(map[uint]string, len(ids))
|
||||||
|
for id := range ids {
|
||||||
|
if c, err := st.GetClientByID(id); err == nil {
|
||||||
|
names[id] = c.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostClientNames(st *store.Store, hosts []store.Host) map[uint]string {
|
||||||
|
ids := make(map[uint]struct{})
|
||||||
|
for _, h := range hosts {
|
||||||
|
ids[h.ClientID] = struct{}{}
|
||||||
|
}
|
||||||
|
names := make(map[uint]string, len(ids))
|
||||||
|
for id := range ids {
|
||||||
|
if c, err := st.GetClientByID(id); err == nil {
|
||||||
|
names[id] = c.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/wormhole/wormhole/internal/auth"
|
||||||
|
"github.com/wormhole/wormhole/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) authMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if key := c.GetHeader("X-API-Key"); key != "" {
|
||||||
|
user, err := s.store.ValidateAPIKey(key)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid api key"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Set("claims", &auth.Claims{
|
||||||
|
UserID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Role: user.Role,
|
||||||
|
})
|
||||||
|
c.Set("auth_via", "apikey")
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := c.GetHeader("Authorization")
|
||||||
|
if len(token) > 7 && token[:7] == "Bearer " {
|
||||||
|
token = token[7:]
|
||||||
|
} else {
|
||||||
|
token = c.Query("token")
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims, err := s.auth.ParseToken(token)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Set("claims", claims)
|
||||||
|
c.Set("auth_via", "jwt")
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pageParams(c *gin.Context) store.PageParams {
|
||||||
|
return store.ParsePageQuery(
|
||||||
|
c.DefaultQuery("page", "1"),
|
||||||
|
c.DefaultQuery("page_size", "20"),
|
||||||
|
c.Query("q"),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,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" } } } }
|
||||||
|
}
|
||||||
|
}`
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"reflect"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/wormhole/wormhole/internal/auth"
|
||||||
|
"github.com/wormhole/wormhole/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) getSettings(c *gin.Context) {
|
||||||
|
claims := getClaims(c)
|
||||||
|
if !auth.IsAdmin(claims.Role) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dto := s.cfg.ToDTO()
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"settings": dto,
|
||||||
|
"note": "修改监听端口后需重启 wormhole server 生效",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) updateSettings(c *gin.Context) {
|
||||||
|
claims := getClaims(c)
|
||||||
|
if !auth.IsAdmin(claims.Role) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req config.SettingsDTO
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
oldServer := s.cfg.Server
|
||||||
|
newCfg := config.SettingsFromDTO(req, s.cfg)
|
||||||
|
if err := s.store.SaveConfig(newCfg); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
*s.cfg = *newCfg
|
||||||
|
s.auth.ReloadAuth(&s.cfg.Auth)
|
||||||
|
s.resAuth.ReloadAuth(&s.cfg.Auth)
|
||||||
|
|
||||||
|
restartRequired := !reflect.DeepEqual(oldServer, s.cfg.Server) ||
|
||||||
|
oldServer.TLSCertFile != s.cfg.Server.TLSCertFile ||
|
||||||
|
oldServer.TLSKeyFile != s.cfg.Server.TLSKeyFile
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"settings": s.cfg.ToDTO(),
|
||||||
|
"restart_required": restartRequired,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DirectRemoteIP returns the direct TCP peer address, ignoring X-Forwarded-For.
|
||||||
|
// Use for security decisions (login rate limit, IP rules, resource auth).
|
||||||
|
func DirectRemoteIP(r *http.Request) string {
|
||||||
|
return HostOnly(r.RemoteAddr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddrHost extracts the host portion from a net.Addr string (host:port or [host]:port).
|
||||||
|
func AddrHost(addr net.Addr) string {
|
||||||
|
if addr == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return HostOnly(addr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// HostOnly strips the port from an address string.
|
||||||
|
func HostOnly(addr string) string {
|
||||||
|
if addr == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
host, _, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
return strings.Trim(addr, "[]")
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractRemoteIP returns the client IP, honoring X-Forwarded-For when present.
|
||||||
|
// Use only when forwarding visitor IP to upstream backends behind a trusted proxy.
|
||||||
|
func ExtractRemoteIP(r *http.Request) string {
|
||||||
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||||
|
parts := strings.Split(xff, ",")
|
||||||
|
return strings.TrimSpace(parts[0])
|
||||||
|
}
|
||||||
|
if xri := r.Header.Get("X-Real-IP"); xri != "" {
|
||||||
|
return strings.TrimSpace(xri)
|
||||||
|
}
|
||||||
|
return DirectRemoteIP(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SafeRedirectPath validates a post-login redirect path (relative only).
|
||||||
|
func SafeRedirectPath(raw string) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" || !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/wormhole/wormhole/internal/config"
|
||||||
|
"github.com/wormhole/wormhole/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
UserID uint `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
cfg *config.AuthConfig
|
||||||
|
limiter *LoginLimiter
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(cfg *config.AuthConfig) *Service {
|
||||||
|
return &Service{
|
||||||
|
cfg: cfg,
|
||||||
|
limiter: NewLoginLimiter(cfg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ReloadAuth(cfg *config.AuthConfig) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
*s.cfg = *cfg
|
||||||
|
s.limiter = NewLoginLimiter(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Login(st *store.Store, ip, username, password string) (string, *store.User, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
limiter := s.limiter
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if retry, blocked := limiter.IsBlocked(ip, username); blocked {
|
||||||
|
return "", nil, &LoginBlockedError{RetryAfter: retry}
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := st.GetUserByUsername(username)
|
||||||
|
if err != nil {
|
||||||
|
limiter.RecordFailure(ip, username)
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
return "", nil, errors.New("invalid credentials")
|
||||||
|
}
|
||||||
|
if !user.Status {
|
||||||
|
limiter.RecordFailure(ip, username)
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
return "", nil, errors.New("invalid credentials")
|
||||||
|
}
|
||||||
|
if !store.CheckPassword(user.PasswordHash, password) {
|
||||||
|
limiter.RecordFailure(ip, username)
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
return "", nil, errors.New("invalid credentials")
|
||||||
|
}
|
||||||
|
if user.Role == "visitor" {
|
||||||
|
limiter.RecordFailure(ip, username)
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
return "", nil, errors.New("访客账号无法登录管理后台")
|
||||||
|
}
|
||||||
|
|
||||||
|
limiter.Reset(ip, username)
|
||||||
|
token, err := s.GenerateToken(user)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
return token, user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GenerateToken(user *store.User) (string, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
cfg := s.cfg
|
||||||
|
s.mu.RUnlock()
|
||||||
|
claims := Claims{
|
||||||
|
UserID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Role: user.Role,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(cfg.TokenTTL)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString([]byte(cfg.JWTSecret))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ParseToken(tokenStr string) (*Claims, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
secret := s.cfg.JWTSecret
|
||||||
|
s.mu.RUnlock()
|
||||||
|
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(secret), nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
claims, ok := token.Claims.(*Claims)
|
||||||
|
if !ok || !token.Valid {
|
||||||
|
return nil, errors.New("invalid token")
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsAdmin(role string) bool {
|
||||||
|
return role == "admin"
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsVisitor(role string) bool {
|
||||||
|
return role == "visitor"
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/wormhole/wormhole/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LoginBlockedError struct {
|
||||||
|
RetryAfter time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *LoginBlockedError) Error() string {
|
||||||
|
return "too many login attempts"
|
||||||
|
}
|
||||||
|
|
||||||
|
type loginRecord struct {
|
||||||
|
failures int
|
||||||
|
windowStart time.Time
|
||||||
|
lockedUntil time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
maxAttempts int
|
||||||
|
window time.Duration
|
||||||
|
lockout time.Duration
|
||||||
|
records map[string]*loginRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLoginLimiter(cfg *config.AuthConfig) *LoginLimiter {
|
||||||
|
if cfg.LoginMaxAttempts <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
max := cfg.LoginMaxAttempts
|
||||||
|
window := cfg.LoginWindow
|
||||||
|
lockout := cfg.LoginLockout
|
||||||
|
if window <= 0 {
|
||||||
|
window = 5 * time.Minute
|
||||||
|
}
|
||||||
|
if lockout <= 0 {
|
||||||
|
lockout = 15 * time.Minute
|
||||||
|
}
|
||||||
|
return &LoginLimiter{
|
||||||
|
maxAttempts: max,
|
||||||
|
window: window,
|
||||||
|
lockout: lockout,
|
||||||
|
records: make(map[string]*loginRecord),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LoginLimiter) keys(ip, username string) []string {
|
||||||
|
keys := []string{"ip:" + ip}
|
||||||
|
if username != "" {
|
||||||
|
keys = append(keys, "user:"+username)
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LoginLimiter) IsBlocked(ip, username string) (time.Duration, bool) {
|
||||||
|
if l == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
var maxRetry time.Duration
|
||||||
|
for _, key := range l.keys(ip, username) {
|
||||||
|
rec := l.records[key]
|
||||||
|
if rec == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if now.Before(rec.lockedUntil) {
|
||||||
|
retry := time.Until(rec.lockedUntil)
|
||||||
|
if retry > maxRetry {
|
||||||
|
maxRetry = retry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxRetry, maxRetry > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LoginLimiter) RecordFailure(ip, username string) {
|
||||||
|
if l == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
for _, key := range l.keys(ip, username) {
|
||||||
|
rec := l.records[key]
|
||||||
|
if rec == nil {
|
||||||
|
rec = &loginRecord{windowStart: now}
|
||||||
|
l.records[key] = rec
|
||||||
|
}
|
||||||
|
if now.Before(rec.lockedUntil) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if now.Sub(rec.windowStart) > l.window {
|
||||||
|
rec.failures = 0
|
||||||
|
rec.windowStart = now
|
||||||
|
}
|
||||||
|
rec.failures++
|
||||||
|
if rec.failures >= l.maxAttempts {
|
||||||
|
rec.lockedUntil = now.Add(l.lockout)
|
||||||
|
rec.failures = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LoginLimiter) Reset(ip, username string) {
|
||||||
|
if l == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
for _, key := range l.keys(ip, username) {
|
||||||
|
delete(l.records, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/wormhole/wormhole/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoginLimiterBlocksAfterMaxAttempts(t *testing.T) {
|
||||||
|
l := NewLoginLimiter(&config.AuthConfig{
|
||||||
|
LoginMaxAttempts: 3,
|
||||||
|
LoginWindow: time.Minute,
|
||||||
|
LoginLockout: 10 * time.Minute,
|
||||||
|
})
|
||||||
|
|
||||||
|
ip := "192.168.1.1"
|
||||||
|
user := "admin"
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if _, blocked := l.IsBlocked(ip, user); blocked {
|
||||||
|
t.Fatalf("unexpected block at attempt %d", i+1)
|
||||||
|
}
|
||||||
|
l.RecordFailure(ip, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
retry, blocked := l.IsBlocked(ip, user)
|
||||||
|
if !blocked || retry <= 0 {
|
||||||
|
t.Fatalf("expected block after max attempts, retry=%v blocked=%v", retry, blocked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginLimiterResetClearsBlock(t *testing.T) {
|
||||||
|
l := NewLoginLimiter(&config.AuthConfig{
|
||||||
|
LoginMaxAttempts: 2,
|
||||||
|
LoginWindow: time.Minute,
|
||||||
|
LoginLockout: time.Minute,
|
||||||
|
})
|
||||||
|
|
||||||
|
ip := "10.0.0.1"
|
||||||
|
l.RecordFailure(ip, "u1")
|
||||||
|
l.RecordFailure(ip, "u1")
|
||||||
|
if _, blocked := l.IsBlocked(ip, "u1"); !blocked {
|
||||||
|
t.Fatal("expected blocked")
|
||||||
|
}
|
||||||
|
|
||||||
|
l.Reset(ip, "u1")
|
||||||
|
if _, blocked := l.IsBlocked(ip, "u1"); blocked {
|
||||||
|
t.Fatal("expected unblocked after reset")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginBlockedError(t *testing.T) {
|
||||||
|
err := &LoginBlockedError{RetryAfter: 30 * time.Second}
|
||||||
|
if !errors.As(err, new(*LoginBlockedError)) {
|
||||||
|
t.Fatal("LoginBlockedError should be detectable via errors.As")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/wormhole/wormhole/internal/cache"
|
||||||
|
"github.com/wormhole/wormhole/internal/config"
|
||||||
|
"github.com/wormhole/wormhole/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const accessCookieName = "wh_access"
|
||||||
|
|
||||||
|
type ResourceAuth struct {
|
||||||
|
cache *cache.Manager
|
||||||
|
store *store.Store
|
||||||
|
jwtSecret string
|
||||||
|
limiter *LoginLimiter
|
||||||
|
mu sync.RWMutex
|
||||||
|
grants map[string]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewResourceAuth(c *cache.Manager, st *store.Store, authCfg *config.AuthConfig) *ResourceAuth {
|
||||||
|
ra := &ResourceAuth{
|
||||||
|
cache: c,
|
||||||
|
store: st,
|
||||||
|
jwtSecret: authCfg.JWTSecret,
|
||||||
|
limiter: NewLoginLimiter(authCfg),
|
||||||
|
grants: make(map[string]time.Time),
|
||||||
|
}
|
||||||
|
go ra.cleanupLoop()
|
||||||
|
return ra
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ra *ResourceAuth) ReloadAuth(cfg *config.AuthConfig) {
|
||||||
|
ra.mu.Lock()
|
||||||
|
defer ra.mu.Unlock()
|
||||||
|
ra.jwtSecret = cfg.JWTSecret
|
||||||
|
ra.limiter = NewLoginLimiter(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func grantKey(resourceType string, resourceID uint, ip string) string {
|
||||||
|
return fmt.Sprintf("%s:%d:%s", resourceType, resourceID, ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ra *ResourceAuth) cleanupLoop() {
|
||||||
|
ticker := time.NewTicker(time.Minute)
|
||||||
|
for range ticker.C {
|
||||||
|
now := time.Now()
|
||||||
|
ra.mu.Lock()
|
||||||
|
for k, exp := range ra.grants {
|
||||||
|
if now.After(exp) {
|
||||||
|
delete(ra.grants, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ra.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ra *ResourceAuth) GrantAccess(resourceType string, resourceID uint, ip string, ttl time.Duration) {
|
||||||
|
ra.mu.Lock()
|
||||||
|
defer ra.mu.Unlock()
|
||||||
|
ra.grants[grantKey(resourceType, resourceID, ip)] = time.Now().Add(ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ra *ResourceAuth) HasAccess(resourceType string, resourceID uint, ip string) bool {
|
||||||
|
ra.mu.RLock()
|
||||||
|
defer ra.mu.RUnlock()
|
||||||
|
exp, ok := ra.grants[grantKey(resourceType, resourceID, ip)]
|
||||||
|
return ok && time.Now().Before(exp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ra *ResourceAuth) CheckHTTP(w http.ResponseWriter, r *http.Request, resourceType string, resourceID uint) bool {
|
||||||
|
cfg, userIDs, ok := GetVisitorConfig(ra.cache, resourceType, resourceID)
|
||||||
|
if ok && cfg.Enabled {
|
||||||
|
return ra.CheckVisitorHTTP(w, r, resourceType, resourceID, cfg, userIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
ip := DirectRemoteIP(r)
|
||||||
|
if ra.HasAccess(resourceType, resourceID, ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
policy, ok := ra.cache.GetAuthPolicy(resourceType, resourceID)
|
||||||
|
if !ok || !policy.Enabled {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
switch policy.Type {
|
||||||
|
case "basic":
|
||||||
|
_, pass, ok := r.BasicAuth()
|
||||||
|
if ok && subtle.ConstantTimeCompare([]byte(pass), []byte(policy.Password)) == 1 {
|
||||||
|
ra.GrantAccess(resourceType, resourceID, ip, time.Duration(policy.TTLSeconds)*time.Second)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
w.Header().Set("WWW-Authenticate", `Basic realm="Wormhole"`)
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return false
|
||||||
|
case "form":
|
||||||
|
if r.URL.Path == "/_wormhole/auth" && r.Method == http.MethodPost {
|
||||||
|
_ = r.ParseForm()
|
||||||
|
pass := r.FormValue("password")
|
||||||
|
if subtle.ConstantTimeCompare([]byte(pass), []byte(policy.Password)) == 1 {
|
||||||
|
ra.GrantAccess(resourceType, resourceID, ip, time.Duration(policy.TTLSeconds)*time.Second)
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: accessCookieName, Value: "1", Path: "/", MaxAge: policy.TTLSeconds})
|
||||||
|
redirect := SafeRedirectPath(r.FormValue("redirect"))
|
||||||
|
http.Redirect(w, r, redirect, http.StatusFound)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cookie, err := r.Cookie(accessCookieName); err == nil && cookie.Value == "1" {
|
||||||
|
ra.GrantAccess(resourceType, resourceID, ip, time.Duration(policy.TTLSeconds)*time.Second)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
redirect := r.URL.RequestURI()
|
||||||
|
html := fmt.Sprintf(`<!DOCTYPE html><html><body>
|
||||||
|
<form method="POST" action="/_wormhole/auth">
|
||||||
|
<input type="hidden" name="redirect" value="%s"/>
|
||||||
|
<input type="password" name="password" placeholder="Password"/>
|
||||||
|
<button type="submit">Login</button>
|
||||||
|
</form></body></html>`, redirect)
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = w.Write([]byte(html))
|
||||||
|
return false
|
||||||
|
case "callback":
|
||||||
|
if policy.CallbackURL != "" {
|
||||||
|
token := r.URL.Query().Get("token")
|
||||||
|
if token != "" && token == policy.Password {
|
||||||
|
ra.GrantAccess(resourceType, resourceID, ip, time.Duration(policy.TTLSeconds)*time.Second)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
cb := policy.CallbackURL
|
||||||
|
if stringsContains(cb, "?") {
|
||||||
|
cb += "&"
|
||||||
|
} else {
|
||||||
|
cb += "?"
|
||||||
|
}
|
||||||
|
cb += "redirect=" + r.URL.RequestURI()
|
||||||
|
http.Redirect(w, r, cb, http.StatusFound)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ra *ResourceAuth) CheckTCP(remoteAddr net.Addr, resourceType string, resourceID uint) bool {
|
||||||
|
ip := remoteAddr.String()
|
||||||
|
if host, _, err := net.SplitHostPort(ip); err == nil {
|
||||||
|
ip = host
|
||||||
|
}
|
||||||
|
if ra.HasAccess(resourceType, resourceID, ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
policy, ok := ra.cache.GetAuthPolicy(resourceType, resourceID)
|
||||||
|
if !ok || !policy.Enabled {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringsContains(s, sub string) bool {
|
||||||
|
return len(s) >= len(sub) && (s == sub || len(sub) == 0 || indexOf(s, sub) >= 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexOf(s, sub string) int {
|
||||||
|
for i := 0; i+len(sub) <= len(s); i++ {
|
||||||
|
if s[i:i+len(sub)] == sub {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ra *ResourceAuth) CreatePolicy(st *store.Store, p *store.AuthPolicy) error {
|
||||||
|
return st.CreateAuthPolicy(p)
|
||||||
|
}
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/wormhole/wormhole/internal/cache"
|
||||||
|
"github.com/wormhole/wormhole/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
visitorLoginPath = "/_wormhole/login"
|
||||||
|
visitorCookie = "wh_visitor"
|
||||||
|
)
|
||||||
|
|
||||||
|
type VisitorClaims struct {
|
||||||
|
UserID uint `json:"user_id"`
|
||||||
|
ResourceType string `json:"resource_type"`
|
||||||
|
ResourceID uint `json:"resource_id"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
type VisitorConfig struct {
|
||||||
|
Enabled bool
|
||||||
|
BindMode string
|
||||||
|
UserIDs []uint
|
||||||
|
TTLSeconds int
|
||||||
|
}
|
||||||
|
|
||||||
|
func visitorConfigFromHost(h *store.Host) VisitorConfig {
|
||||||
|
ttl := h.VisitorTTLSeconds
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = 7200
|
||||||
|
}
|
||||||
|
return VisitorConfig{
|
||||||
|
Enabled: h.VisitorAuthEnabled,
|
||||||
|
BindMode: h.VisitorBindMode,
|
||||||
|
TTLSeconds: ttl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func visitorConfigFromTunnel(t *store.Tunnel) VisitorConfig {
|
||||||
|
ttl := t.VisitorTTLSeconds
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = 7200
|
||||||
|
}
|
||||||
|
return VisitorConfig{
|
||||||
|
Enabled: t.VisitorAuthEnabled,
|
||||||
|
BindMode: t.VisitorBindMode,
|
||||||
|
TTLSeconds: ttl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ra *ResourceAuth) CheckVisitorHTTP(w http.ResponseWriter, r *http.Request, resourceType string, resourceID uint, cfg VisitorConfig, userIDs []uint) bool {
|
||||||
|
if !cfg.Enabled {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
cfg.UserIDs = userIDs
|
||||||
|
if bindMode := strings.TrimSpace(cfg.BindMode); bindMode == "" {
|
||||||
|
cfg.BindMode = "all"
|
||||||
|
} else {
|
||||||
|
cfg.BindMode = bindMode
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.URL.Path == visitorLoginPath {
|
||||||
|
ra.handleVisitorLogin(w, r, resourceType, resourceID, cfg)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims, ok := ra.parseVisitorCookie(r, resourceType, resourceID); ok {
|
||||||
|
if ra.visitorAllowed(claims.UserID, cfg) {
|
||||||
|
ra.logVisitorAccess(claims.UserID, resourceType, resourceID, "access", r)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect := r.URL.RequestURI()
|
||||||
|
http.Redirect(w, r, visitorLoginPath+"?redirect="+url.QueryEscape(redirect), http.StatusFound)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func visitorLimiterIP(resourceType string, resourceID uint, ip string) string {
|
||||||
|
return fmt.Sprintf("%s:%d:%s", resourceType, resourceID, ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ra *ResourceAuth) handleVisitorLogin(w http.ResponseWriter, r *http.Request, resourceType string, resourceID uint, cfg VisitorConfig) {
|
||||||
|
ip := DirectRemoteIP(r)
|
||||||
|
scopedIP := visitorLimiterIP(resourceType, resourceID, ip)
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodPost:
|
||||||
|
_ = r.ParseForm()
|
||||||
|
username := strings.TrimSpace(r.FormValue("username"))
|
||||||
|
if retry, blocked := ra.limiter.IsBlocked(scopedIP, username); blocked {
|
||||||
|
ra.renderVisitorLoginBlocked(w, r, cfg, retry)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
password := r.FormValue("password")
|
||||||
|
user, err := ra.authenticateVisitor(username, password, cfg)
|
||||||
|
if err != nil {
|
||||||
|
ra.limiter.RecordFailure(scopedIP, username)
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
ra.renderVisitorLogin(w, r, cfg, "用户名或密码错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ra.limiter.Reset(scopedIP, username)
|
||||||
|
token, err := ra.signVisitorToken(user.ID, resourceType, resourceID, cfg.TTLSeconds)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: visitorCookie,
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: cfg.TTLSeconds,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
Secure: secure,
|
||||||
|
})
|
||||||
|
redirect := SafeRedirectPath(r.FormValue("redirect"))
|
||||||
|
ra.logVisitorAccess(user.ID, resourceType, resourceID, "login", r)
|
||||||
|
http.Redirect(w, r, redirect, http.StatusFound)
|
||||||
|
case http.MethodGet:
|
||||||
|
if retry, blocked := ra.limiter.IsBlocked(scopedIP, ""); blocked {
|
||||||
|
ra.renderVisitorLoginBlocked(w, r, cfg, retry)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ra.renderVisitorLogin(w, r, cfg, "")
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ra *ResourceAuth) authenticateVisitor(username, password string, cfg VisitorConfig) (*store.User, error) {
|
||||||
|
if username == "" || password == "" {
|
||||||
|
return nil, errors.New("invalid credentials")
|
||||||
|
}
|
||||||
|
user, err := ra.store.GetUserByUsername(username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("invalid credentials")
|
||||||
|
}
|
||||||
|
if user.Role != "visitor" || !user.Status {
|
||||||
|
return nil, errors.New("invalid credentials")
|
||||||
|
}
|
||||||
|
if !store.CheckPassword(user.PasswordHash, password) {
|
||||||
|
return nil, errors.New("invalid credentials")
|
||||||
|
}
|
||||||
|
if !ra.visitorAllowed(user.ID, cfg) {
|
||||||
|
return nil, errors.New("not allowed")
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ra *ResourceAuth) visitorAllowed(userID uint, cfg VisitorConfig) bool {
|
||||||
|
if cfg.BindMode == "all" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, id := range cfg.UserIDs {
|
||||||
|
if id == userID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ra *ResourceAuth) parseVisitorCookie(r *http.Request, resourceType string, resourceID uint) (*VisitorClaims, bool) {
|
||||||
|
cookie, err := r.Cookie(visitorCookie)
|
||||||
|
if err != nil || cookie.Value == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
claims, err := ra.parseVisitorToken(cookie.Value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if claims.ResourceType != resourceType || claims.ResourceID != resourceID {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return claims, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ra *ResourceAuth) signVisitorToken(userID uint, resourceType string, resourceID uint, ttlSeconds int) (string, error) {
|
||||||
|
if ttlSeconds <= 0 {
|
||||||
|
ttlSeconds = 7200
|
||||||
|
}
|
||||||
|
claims := VisitorClaims{
|
||||||
|
UserID: userID,
|
||||||
|
ResourceType: resourceType,
|
||||||
|
ResourceID: resourceID,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(ttlSeconds) * time.Second)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString([]byte(ra.jwtSecret))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ra *ResourceAuth) parseVisitorToken(tokenStr string) (*VisitorClaims, error) {
|
||||||
|
token, err := jwt.ParseWithClaims(tokenStr, &VisitorClaims{}, func(t *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(ra.jwtSecret), nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
claims, ok := token.Claims.(*VisitorClaims)
|
||||||
|
if !ok || !token.Valid {
|
||||||
|
return nil, errors.New("invalid token")
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ra *ResourceAuth) renderVisitorLogin(w http.ResponseWriter, r *http.Request, cfg VisitorConfig, errMsg string) {
|
||||||
|
redirect := SafeRedirectPath(r.URL.Query().Get("redirect"))
|
||||||
|
errHTML := ""
|
||||||
|
if errMsg != "" {
|
||||||
|
errHTML = fmt.Sprintf(`<p class="error">%s</p>`, html.EscapeString(errMsg))
|
||||||
|
}
|
||||||
|
page := fmt.Sprintf(`<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<title>登录验证</title>
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f1419;color:#e7e9ea}
|
||||||
|
.card{width:100%%;max-width:380px;padding:32px 28px;background:#16181c;border:1px solid #2f3336;border-radius:16px;box-shadow:0 8px 32px rgba(0,0,0,.35)}
|
||||||
|
h1{margin:0 0 8px;font-size:22px;font-weight:600}
|
||||||
|
.sub{margin:0 0 24px;color:#71767b;font-size:14px}
|
||||||
|
label{display:block;margin-bottom:6px;font-size:13px;color:#71767b}
|
||||||
|
input{width:100%%;padding:10px 12px;margin-bottom:14px;border:1px solid #2f3336;border-radius:8px;background:#000;color:#e7e9ea;font-size:15px}
|
||||||
|
input:focus{outline:none;border-color:#1d9bf0}
|
||||||
|
button{width:100%%;padding:11px;border:none;border-radius:999px;background:#1d9bf0;color:#fff;font-size:15px;font-weight:600;cursor:pointer}
|
||||||
|
button:hover{background:#1a8cd8}
|
||||||
|
.error{color:#f4212e;font-size:13px;margin:0 0 12px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>登录验证</h1>
|
||||||
|
<p class="sub">此资源已开启访客登录,请使用分配的账号访问。</p>
|
||||||
|
%s
|
||||||
|
<form method="POST" action="%s">
|
||||||
|
<input type="hidden" name="redirect" value="%s"/>
|
||||||
|
<label>用户名</label>
|
||||||
|
<input name="username" autocomplete="username" required autofocus/>
|
||||||
|
<label>密码</label>
|
||||||
|
<input name="password" type="password" autocomplete="current-password" required/>
|
||||||
|
<button type="submit">登录</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`, errHTML, visitorLoginPath, html.EscapeString(redirect))
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(page))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ra *ResourceAuth) renderVisitorLoginBlocked(w http.ResponseWriter, r *http.Request, _ VisitorConfig, retryAfter time.Duration) {
|
||||||
|
redirect := SafeRedirectPath(r.URL.Query().Get("redirect"))
|
||||||
|
minutes := int(retryAfter.Minutes())
|
||||||
|
if minutes < 1 {
|
||||||
|
minutes = 1
|
||||||
|
}
|
||||||
|
msg := fmt.Sprintf("登录尝试次数过多,请 %d 分钟后再试", minutes)
|
||||||
|
if retryAfter < time.Minute {
|
||||||
|
secs := int(retryAfter.Seconds())
|
||||||
|
if secs < 1 {
|
||||||
|
secs = 1
|
||||||
|
}
|
||||||
|
msg = fmt.Sprintf("登录尝试次数过多,请 %d 秒后再试", secs)
|
||||||
|
}
|
||||||
|
retrySecs := int(retryAfter.Seconds()) + 1
|
||||||
|
if retrySecs < 1 {
|
||||||
|
retrySecs = 1
|
||||||
|
}
|
||||||
|
w.Header().Set("Retry-After", strconv.Itoa(retrySecs))
|
||||||
|
loginURL := visitorLoginPath + "?redirect=" + url.QueryEscape(redirect)
|
||||||
|
page := fmt.Sprintf(`<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<title>登录验证</title>
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f1419;color:#e7e9ea}
|
||||||
|
.card{width:100%%;max-width:380px;padding:32px 28px;background:#16181c;border:1px solid #2f3336;border-radius:16px;box-shadow:0 8px 32px rgba(0,0,0,.35)}
|
||||||
|
h1{margin:0 0 8px;font-size:22px;font-weight:600}
|
||||||
|
.sub{margin:0 0 16px;color:#71767b;font-size:14px}
|
||||||
|
.error{color:#f4212e;font-size:14px;margin:0 0 16px;line-height:1.5}
|
||||||
|
a{color:#1d9bf0;text-decoration:none}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>登录验证</h1>
|
||||||
|
<p class="sub">此资源已开启访客登录,请使用分配的账号访问。</p>
|
||||||
|
<p class="error">%s</p>
|
||||||
|
<p class="sub"><a href="%s">稍后再试</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`, html.EscapeString(msg), html.EscapeString(loginURL))
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
_, _ = w.Write([]byte(page))
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetVisitorConfig(c *cache.Manager, resourceType string, resourceID uint) (VisitorConfig, []uint, bool) {
|
||||||
|
switch resourceType {
|
||||||
|
case "host":
|
||||||
|
h, ok := c.GetHost(resourceID)
|
||||||
|
if !ok {
|
||||||
|
return VisitorConfig{}, nil, false
|
||||||
|
}
|
||||||
|
cfg := visitorConfigFromHost(h)
|
||||||
|
ids, _ := c.GetResourceVisitorIDs(resourceType, resourceID)
|
||||||
|
return cfg, ids, true
|
||||||
|
case "tunnel":
|
||||||
|
t, ok := c.GetTunnel(resourceID)
|
||||||
|
if !ok {
|
||||||
|
return VisitorConfig{}, nil, false
|
||||||
|
}
|
||||||
|
cfg := visitorConfigFromTunnel(t)
|
||||||
|
ids, _ := c.GetResourceVisitorIDs(resourceType, resourceID)
|
||||||
|
return cfg, ids, true
|
||||||
|
default:
|
||||||
|
return VisitorConfig{}, nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/wormhole/wormhole/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func shouldLogVisitorAccess(reqPath string) bool {
|
||||||
|
if reqPath == visitorLoginPath {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
base := strings.Split(reqPath, "?")[0]
|
||||||
|
ext := strings.ToLower(path.Ext(base))
|
||||||
|
switch ext {
|
||||||
|
case ".js", ".css", ".ico", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg",
|
||||||
|
".woff", ".woff2", ".ttf", ".eot", ".map", ".wasm":
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ra *ResourceAuth) logVisitorAccess(userID uint, resourceType string, resourceID uint, action string, r *http.Request) {
|
||||||
|
if ra.store == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reqPath := r.URL.RequestURI()
|
||||||
|
if action == "access" && !shouldLogVisitorAccess(r.URL.Path) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(reqPath) > 512 {
|
||||||
|
reqPath = reqPath[:512]
|
||||||
|
}
|
||||||
|
ua := r.UserAgent()
|
||||||
|
if len(ua) > 256 {
|
||||||
|
ua = ua[:256]
|
||||||
|
}
|
||||||
|
entry := &store.VisitorAccessLog{
|
||||||
|
UserID: userID,
|
||||||
|
ResourceType: resourceType,
|
||||||
|
ResourceID: resourceID,
|
||||||
|
Action: action,
|
||||||
|
Method: r.Method,
|
||||||
|
Path: reqPath,
|
||||||
|
IP: ExtractRemoteIP(r),
|
||||||
|
UserAgent: ua,
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
_ = ra.store.AppendVisitorAccessLog(entry)
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
package bridge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/xtaci/smux"
|
||||||
|
"github.com/wormhole/wormhole/internal/cache"
|
||||||
|
"github.com/wormhole/wormhole/internal/protocol"
|
||||||
|
"github.com/wormhole/wormhole/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AgentSession struct {
|
||||||
|
ClientID uint
|
||||||
|
Version string
|
||||||
|
conn net.Conn
|
||||||
|
smux *smux.Session
|
||||||
|
mu sync.Mutex
|
||||||
|
closed atomic.Bool
|
||||||
|
activeConns atomic.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type Bridge struct {
|
||||||
|
cache *cache.Manager
|
||||||
|
store *store.Store
|
||||||
|
mu sync.RWMutex
|
||||||
|
agents map[uint]*AgentSession
|
||||||
|
listener net.Listener
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(c *cache.Manager, st *store.Store) *Bridge {
|
||||||
|
return &Bridge{
|
||||||
|
cache: c,
|
||||||
|
store: st,
|
||||||
|
agents: make(map[uint]*AgentSession),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) Start(addr string, certFile, keyFile string) error {
|
||||||
|
var ln net.Listener
|
||||||
|
var err error
|
||||||
|
if certFile != "" && keyFile != "" {
|
||||||
|
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load bridge tls cert: %w", err)
|
||||||
|
}
|
||||||
|
ln, err = tls.Listen("tcp", addr, &tls.Config{
|
||||||
|
Certificates: []tls.Certificate{cert},
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("[bridge] listening on %s (TLS)", addr)
|
||||||
|
} else {
|
||||||
|
ln, err = net.Listen("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("[bridge] listening on %s (plain TCP, configure TLS cert for production)", addr)
|
||||||
|
}
|
||||||
|
b.listener = ln
|
||||||
|
go b.acceptLoop()
|
||||||
|
b.startWatchdog()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) Shutdown() error {
|
||||||
|
if b.listener != nil {
|
||||||
|
return b.listener.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) acceptLoop() {
|
||||||
|
for {
|
||||||
|
conn, err := b.listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go b.handleConn(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) validateClient(client *store.Client) error {
|
||||||
|
if !client.Status {
|
||||||
|
return fmt.Errorf("client disabled")
|
||||||
|
}
|
||||||
|
if client.ExpireAt != nil && time.Now().After(*client.ExpireAt) {
|
||||||
|
return fmt.Errorf("client expired")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) handleConn(conn net.Conn) {
|
||||||
|
defer conn.Close()
|
||||||
|
SetTCPKeepAlive(conn)
|
||||||
|
msgType, body, err := protocol.ReadMessage(conn)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[bridge] read register failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if msgType != protocol.MsgRegister {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reg, err := protocol.ParseRegister(body)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client, ok := b.cache.GetClientByKey(reg.VerifyKey)
|
||||||
|
if !ok {
|
||||||
|
_ = protocol.WriteMessage(conn, protocol.MsgRegisterAck, protocol.RegisterAckPayload{
|
||||||
|
OK: false,
|
||||||
|
Message: "invalid verify key",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := b.validateClient(client); err != nil {
|
||||||
|
_ = protocol.WriteMessage(conn, protocol.MsgRegisterAck, protocol.RegisterAckPayload{
|
||||||
|
OK: false,
|
||||||
|
Message: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := protocol.WriteMessage(conn, protocol.MsgRegisterAck, protocol.RegisterAckPayload{
|
||||||
|
OK: true,
|
||||||
|
ClientID: client.ID,
|
||||||
|
Message: "registered",
|
||||||
|
}); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := smux.Client(conn, smuxConfig())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[bridge] smux client failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
agent := &AgentSession{
|
||||||
|
ClientID: client.ID,
|
||||||
|
Version: reg.Version,
|
||||||
|
conn: conn,
|
||||||
|
smux: session,
|
||||||
|
}
|
||||||
|
b.mu.Lock()
|
||||||
|
if old, exists := b.agents[client.ID]; exists {
|
||||||
|
old.Close()
|
||||||
|
}
|
||||||
|
b.agents[client.ID] = agent
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
b.cache.SetClientOnline(client.ID, reg.Version)
|
||||||
|
log.Printf("[bridge] client %d online (version=%s)", client.ID, reg.Version)
|
||||||
|
|
||||||
|
b.controlLoop(agent)
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
delete(b.agents, client.ID)
|
||||||
|
b.mu.Unlock()
|
||||||
|
b.cache.SetClientOffline(client.ID)
|
||||||
|
log.Printf("[bridge] client %d offline", client.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) controlLoop(agent *AgentSession) {
|
||||||
|
for {
|
||||||
|
stream, err := agent.smux.AcceptStream()
|
||||||
|
if err != nil {
|
||||||
|
agent.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go b.handleControlStream(agent, stream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) handleControlStream(agent *AgentSession, stream *smux.Stream) {
|
||||||
|
defer stream.Close()
|
||||||
|
msgType, body, err := protocol.ReadMessage(stream)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch msgType {
|
||||||
|
case protocol.MsgPing:
|
||||||
|
b.cache.TouchAgent(agent.ClientID)
|
||||||
|
_ = protocol.WriteMessage(stream, protocol.MsgPong, nil)
|
||||||
|
case protocol.MsgClose:
|
||||||
|
agent.Close()
|
||||||
|
}
|
||||||
|
_ = body
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AgentSession) Close() {
|
||||||
|
if a.closed.Swap(true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.smux != nil {
|
||||||
|
_ = a.smux.Close()
|
||||||
|
}
|
||||||
|
if a.conn != nil {
|
||||||
|
_ = a.conn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) OpenStream(clientID uint, meta *protocol.LinkMeta) (net.Conn, error) {
|
||||||
|
client, err := b.store.GetClientByID(clientID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("client %d not found", clientID)
|
||||||
|
}
|
||||||
|
if err := b.validateClient(client); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
b.mu.RLock()
|
||||||
|
agent, ok := b.agents[clientID]
|
||||||
|
b.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("client %d offline", clientID)
|
||||||
|
}
|
||||||
|
if agent.closed.Load() {
|
||||||
|
return nil, fmt.Errorf("client %d session closed", clientID)
|
||||||
|
}
|
||||||
|
if client.MaxConn > 0 && int(agent.activeConns.Load()) >= client.MaxConn {
|
||||||
|
return nil, fmt.Errorf("client %d max connections reached", clientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
stream, err := agent.smux.OpenStream()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := protocol.WriteMessage(stream, protocol.MsgOpenStream, meta); err != nil {
|
||||||
|
stream.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
msgType, _, err := protocol.ReadMessage(stream)
|
||||||
|
if err != nil {
|
||||||
|
stream.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if msgType != protocol.MsgStreamReady {
|
||||||
|
stream.Close()
|
||||||
|
return nil, fmt.Errorf("stream not ready")
|
||||||
|
}
|
||||||
|
|
||||||
|
agent.activeConns.Add(1)
|
||||||
|
var wrapped net.Conn = &trackedConn{
|
||||||
|
Conn: stream,
|
||||||
|
onClose: func() { agent.activeConns.Add(-1) },
|
||||||
|
}
|
||||||
|
if client.RateLimit > 0 {
|
||||||
|
wrapped = newRateLimitedConn(wrapped, client.RateLimit*1024)
|
||||||
|
}
|
||||||
|
return wrapped, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) IsOnline(clientID uint) bool {
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
_, ok := b.agents[clientID]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) OnlineCount() int {
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
return len(b.agents)
|
||||||
|
}
|
||||||
|
|
||||||
|
type trackedConn struct {
|
||||||
|
net.Conn
|
||||||
|
onClose func()
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *trackedConn) Close() error {
|
||||||
|
c.once.Do(c.onClose)
|
||||||
|
return c.Conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyBoth(a, b net.Conn) (inlet, export int64) {
|
||||||
|
var inVal, outVal int64
|
||||||
|
done := make(chan struct{}, 2)
|
||||||
|
go func() {
|
||||||
|
n, _ := io.Copy(b, a)
|
||||||
|
atomic.AddInt64(&outVal, n)
|
||||||
|
done <- struct{}{}
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
n, _ := io.Copy(a, b)
|
||||||
|
atomic.AddInt64(&inVal, n)
|
||||||
|
done <- struct{}{}
|
||||||
|
}()
|
||||||
|
<-done
|
||||||
|
<-done
|
||||||
|
return atomic.LoadInt64(&inVal), atomic.LoadInt64(&outVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyBothIO(a io.ReadWriteCloser, b io.ReadWriteCloser) (inlet, export int64) {
|
||||||
|
var inVal, outVal int64
|
||||||
|
done := make(chan struct{}, 2)
|
||||||
|
go func() {
|
||||||
|
n, _ := io.Copy(b, a)
|
||||||
|
atomic.AddInt64(&outVal, n)
|
||||||
|
done <- struct{}{}
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
n, _ := io.Copy(a, b)
|
||||||
|
atomic.AddInt64(&inVal, n)
|
||||||
|
done <- struct{}{}
|
||||||
|
}()
|
||||||
|
<-done
|
||||||
|
<-done
|
||||||
|
return atomic.LoadInt64(&inVal), atomic.LoadInt64(&outVal)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package bridge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/xtaci/smux"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// HeartbeatInterval is the application-level ping interval (agent and server probe).
|
||||||
|
HeartbeatInterval = 30 * time.Second
|
||||||
|
// HeartbeatTimeout evicts a session when no ping/pong for this duration.
|
||||||
|
HeartbeatTimeout = 90 * time.Second
|
||||||
|
// WatchdogInterval is how often the server checks agent heartbeats.
|
||||||
|
WatchdogInterval = 15 * time.Second
|
||||||
|
// ServerProbeAfter triggers a server-initiated ping when agent has been silent.
|
||||||
|
ServerProbeAfter = 45 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
func SmuxConfig() *smux.Config {
|
||||||
|
return smuxConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
func smuxConfig() *smux.Config {
|
||||||
|
cfg := smux.DefaultConfig()
|
||||||
|
cfg.KeepAliveInterval = 10 * time.Second
|
||||||
|
cfg.KeepAliveTimeout = 30 * time.Second
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetTCPKeepAlive(conn net.Conn) {
|
||||||
|
for {
|
||||||
|
switch c := conn.(type) {
|
||||||
|
case *net.TCPConn:
|
||||||
|
_ = c.SetKeepAlive(true)
|
||||||
|
_ = c.SetKeepAlivePeriod(30 * time.Second)
|
||||||
|
return
|
||||||
|
case *tls.Conn:
|
||||||
|
conn = c.NetConn()
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package bridge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rateLimitedConn limits read/write throughput to bytesPerSec (simple token bucket).
|
||||||
|
type rateLimitedConn struct {
|
||||||
|
net.Conn
|
||||||
|
bytesPerSec int64
|
||||||
|
mu sync.Mutex
|
||||||
|
allowance float64
|
||||||
|
lastCheck time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRateLimitedConn(conn net.Conn, bytesPerSec int64) net.Conn {
|
||||||
|
if bytesPerSec <= 0 {
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
return &rateLimitedConn{
|
||||||
|
Conn: conn,
|
||||||
|
bytesPerSec: bytesPerSec,
|
||||||
|
lastCheck: time.Now(),
|
||||||
|
allowance: float64(bytesPerSec),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *rateLimitedConn) wait(n int) {
|
||||||
|
if c.bytesPerSec <= 0 || n <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
now := time.Now()
|
||||||
|
elapsed := now.Sub(c.lastCheck).Seconds()
|
||||||
|
c.lastCheck = now
|
||||||
|
c.allowance += elapsed * float64(c.bytesPerSec)
|
||||||
|
if c.allowance > float64(c.bytesPerSec)*2 {
|
||||||
|
c.allowance = float64(c.bytesPerSec) * 2
|
||||||
|
}
|
||||||
|
for float64(n) > c.allowance {
|
||||||
|
deficit := float64(n) - c.allowance
|
||||||
|
sleep := time.Duration(deficit/float64(c.bytesPerSec)*1e9) * time.Nanosecond
|
||||||
|
if sleep < time.Millisecond {
|
||||||
|
sleep = time.Millisecond
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
time.Sleep(sleep)
|
||||||
|
c.mu.Lock()
|
||||||
|
now = time.Now()
|
||||||
|
elapsed = now.Sub(c.lastCheck).Seconds()
|
||||||
|
c.lastCheck = now
|
||||||
|
c.allowance += elapsed * float64(c.bytesPerSec)
|
||||||
|
if c.allowance > float64(c.bytesPerSec)*2 {
|
||||||
|
c.allowance = float64(c.bytesPerSec) * 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.allowance -= float64(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *rateLimitedConn) Read(b []byte) (int, error) {
|
||||||
|
n, err := c.Conn.Read(b)
|
||||||
|
if n > 0 {
|
||||||
|
c.wait(n)
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *rateLimitedConn) Write(b []byte) (int, error) {
|
||||||
|
c.wait(len(b))
|
||||||
|
return c.Conn.Write(b)
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package bridge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/wormhole/wormhole/internal/protocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (b *Bridge) startWatchdog() {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(WatchdogInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
b.runWatchdog()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) runWatchdog() {
|
||||||
|
now := time.Now()
|
||||||
|
b.mu.RLock()
|
||||||
|
sessions := make([]*AgentSession, 0, len(b.agents))
|
||||||
|
for _, agent := range b.agents {
|
||||||
|
sessions = append(sessions, agent)
|
||||||
|
}
|
||||||
|
b.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, agent := range sessions {
|
||||||
|
if agent.closed.Load() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lastPing, ok := b.cache.AgentLastPing(agent.ClientID)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
since := now.Sub(lastPing)
|
||||||
|
if since > HeartbeatTimeout {
|
||||||
|
log.Printf("[bridge] client %d heartbeat timeout (%v), closing", agent.ClientID, since.Round(time.Second))
|
||||||
|
agent.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if since > ServerProbeAfter {
|
||||||
|
go b.probeAgent(agent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) probeAgent(agent *AgentSession) {
|
||||||
|
if agent.closed.Load() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stream, err := agent.smux.OpenStream()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer stream.Close()
|
||||||
|
|
||||||
|
if err := protocol.WriteMessage(stream, protocol.MsgPing, nil); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msgType, _, err := protocol.ReadMessage(stream)
|
||||||
|
if err != nil || msgType != protocol.MsgPong {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.cache.TouchAgent(agent.ClientID)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultDBPath = "./data/wormhole.db"
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Server ServerConfig `yaml:"server" json:"server"`
|
||||||
|
Database DatabaseConfig `yaml:"database" json:"database"`
|
||||||
|
Auth AuthConfig `yaml:"auth" json:"auth"`
|
||||||
|
Metrics MetricsConfig `yaml:"metrics" json:"metrics"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerConfig struct {
|
||||||
|
BridgeAddr string `yaml:"bridge_addr" json:"bridge_addr"`
|
||||||
|
HTTPAddr string `yaml:"http_addr" json:"http_addr"`
|
||||||
|
HTTPProxyPort int `yaml:"http_proxy_port" json:"http_proxy_port"`
|
||||||
|
HTTPSProxyPort int `yaml:"https_proxy_port" json:"https_proxy_port"`
|
||||||
|
TLSCertFile string `yaml:"tls_cert_file" json:"tls_cert_file"`
|
||||||
|
TLSKeyFile string `yaml:"tls_key_file" json:"tls_key_file"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DatabaseConfig struct {
|
||||||
|
Path string `yaml:"path" json:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthConfig struct {
|
||||||
|
JWTSecret string `yaml:"jwt_secret" json:"jwt_secret"`
|
||||||
|
TokenTTL time.Duration `yaml:"token_ttl" json:"token_ttl"`
|
||||||
|
LoginMaxAttempts int `yaml:"login_max_attempts" json:"login_max_attempts"`
|
||||||
|
LoginWindow time.Duration `yaml:"login_window" json:"login_window"`
|
||||||
|
LoginLockout time.Duration `yaml:"login_lockout" json:"login_lockout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MetricsConfig struct {
|
||||||
|
FlushInterval time.Duration `yaml:"flush_interval" json:"flush_interval"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettingsDTO is the API/UI representation of runtime settings.
|
||||||
|
type SettingsDTO struct {
|
||||||
|
Server ServerConfig `json:"server"`
|
||||||
|
Auth AuthSettings `json:"auth"`
|
||||||
|
Metrics struct {
|
||||||
|
FlushIntervalSec int `json:"flush_interval_sec"`
|
||||||
|
} `json:"metrics"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthSettings struct {
|
||||||
|
JWTSecret string `json:"jwt_secret,omitempty"`
|
||||||
|
TokenTTLHours int `json:"token_ttl_hours"`
|
||||||
|
LoginMaxAttempts int `json:"login_max_attempts"`
|
||||||
|
LoginWindowMin int `json:"login_window_min"`
|
||||||
|
LoginLockoutMin int `json:"login_lockout_min"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Default() *Config {
|
||||||
|
secret, _ := randomSecret(32)
|
||||||
|
return &Config{
|
||||||
|
Server: ServerConfig{
|
||||||
|
BridgeAddr: ":8528",
|
||||||
|
HTTPAddr: ":8529",
|
||||||
|
HTTPProxyPort: 8081,
|
||||||
|
HTTPSProxyPort: 8443,
|
||||||
|
},
|
||||||
|
Database: DatabaseConfig{
|
||||||
|
Path: DefaultDBPath,
|
||||||
|
},
|
||||||
|
Auth: AuthConfig{
|
||||||
|
JWTSecret: secret,
|
||||||
|
TokenTTL: 24 * time.Hour,
|
||||||
|
LoginMaxAttempts: 5,
|
||||||
|
LoginWindow: 5 * time.Minute,
|
||||||
|
LoginLockout: 15 * time.Minute,
|
||||||
|
},
|
||||||
|
Metrics: MetricsConfig{
|
||||||
|
FlushInterval: 30 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomSecret(n int) (string, error) {
|
||||||
|
b := make([]byte, n)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "wormhole-change-me-in-production", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) Normalize() {
|
||||||
|
if c.Server.BridgeAddr == "" {
|
||||||
|
c.Server.BridgeAddr = ":8528"
|
||||||
|
}
|
||||||
|
if c.Server.HTTPAddr == "" {
|
||||||
|
c.Server.HTTPAddr = ":8529"
|
||||||
|
}
|
||||||
|
if c.Server.HTTPProxyPort == 0 {
|
||||||
|
c.Server.HTTPProxyPort = 8081
|
||||||
|
}
|
||||||
|
if c.Server.HTTPSProxyPort == 0 {
|
||||||
|
c.Server.HTTPSProxyPort = 8443
|
||||||
|
}
|
||||||
|
if c.Database.Path == "" {
|
||||||
|
c.Database.Path = DefaultDBPath
|
||||||
|
}
|
||||||
|
if c.Auth.TokenTTL == 0 {
|
||||||
|
c.Auth.TokenTTL = 24 * time.Hour
|
||||||
|
}
|
||||||
|
if c.Auth.LoginMaxAttempts == 0 {
|
||||||
|
c.Auth.LoginMaxAttempts = 5
|
||||||
|
}
|
||||||
|
if c.Auth.LoginWindow == 0 {
|
||||||
|
c.Auth.LoginWindow = 5 * time.Minute
|
||||||
|
}
|
||||||
|
if c.Auth.LoginLockout == 0 {
|
||||||
|
c.Auth.LoginLockout = 15 * time.Minute
|
||||||
|
}
|
||||||
|
if c.Auth.JWTSecret == "" {
|
||||||
|
secret, _ := randomSecret(32)
|
||||||
|
c.Auth.JWTSecret = secret
|
||||||
|
}
|
||||||
|
if c.Metrics.FlushInterval == 0 {
|
||||||
|
c.Metrics.FlushInterval = 30 * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) ToDTO() SettingsDTO {
|
||||||
|
sec := int(c.Metrics.FlushInterval / time.Second)
|
||||||
|
if sec < 1 {
|
||||||
|
sec = 30
|
||||||
|
}
|
||||||
|
dto := SettingsDTO{
|
||||||
|
Server: c.Server,
|
||||||
|
Auth: AuthSettings{
|
||||||
|
TokenTTLHours: int(c.Auth.TokenTTL / time.Hour),
|
||||||
|
LoginMaxAttempts: c.Auth.LoginMaxAttempts,
|
||||||
|
LoginWindowMin: int(c.Auth.LoginWindow / time.Minute),
|
||||||
|
LoginLockoutMin: int(c.Auth.LoginLockout / time.Minute),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dto.Metrics.FlushIntervalSec = sec
|
||||||
|
return dto
|
||||||
|
}
|
||||||
|
|
||||||
|
func SettingsFromDTO(d SettingsDTO, existing *Config) *Config {
|
||||||
|
cfg := Default()
|
||||||
|
if existing != nil {
|
||||||
|
*cfg = *existing
|
||||||
|
}
|
||||||
|
cfg.Server = d.Server
|
||||||
|
cfg.Auth.TokenTTL = time.Duration(d.Auth.TokenTTLHours) * time.Hour
|
||||||
|
cfg.Auth.LoginMaxAttempts = d.Auth.LoginMaxAttempts
|
||||||
|
cfg.Auth.LoginWindow = time.Duration(d.Auth.LoginWindowMin) * time.Minute
|
||||||
|
cfg.Auth.LoginLockout = time.Duration(d.Auth.LoginLockoutMin) * time.Minute
|
||||||
|
if d.Auth.JWTSecret != "" {
|
||||||
|
cfg.Auth.JWTSecret = d.Auth.JWTSecret
|
||||||
|
}
|
||||||
|
if d.Metrics.FlushIntervalSec > 0 {
|
||||||
|
cfg.Metrics.FlushInterval = time.Duration(d.Metrics.FlushIntervalSec) * time.Second
|
||||||
|
}
|
||||||
|
cfg.Normalize()
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) Summary() string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"http_addr=%s bridge_addr=%s http_proxy_port=%d https_proxy_port=%d db=%s",
|
||||||
|
c.Server.HTTPAddr,
|
||||||
|
c.Server.BridgeAddr,
|
||||||
|
c.Server.HTTPProxyPort,
|
||||||
|
c.Server.HTTPSProxyPort,
|
||||||
|
c.Database.Path,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads config from a YAML file (tests only).
|
||||||
|
func Load(path string) (*Config, error) {
|
||||||
|
if path == "" {
|
||||||
|
return nil, fmt.Errorf("config path required")
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := Default()
|
||||||
|
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Normalize()
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadUsesFileValues(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfgPath := filepath.Join(dir, "wormhole.yaml")
|
||||||
|
content := []byte(`server:
|
||||||
|
bridge_addr: ":9528"
|
||||||
|
http_addr: ":9529"
|
||||||
|
database:
|
||||||
|
path: "./tmp.db"
|
||||||
|
auth:
|
||||||
|
jwt_secret: "test-secret"
|
||||||
|
token_ttl: 1h
|
||||||
|
metrics:
|
||||||
|
flush_interval: 10s
|
||||||
|
`)
|
||||||
|
if err := os.WriteFile(cfgPath, content, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := Load(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.Server.BridgeAddr != ":9528" || cfg.Server.HTTPAddr != ":9529" {
|
||||||
|
t.Fatalf("server config not applied: %+v", cfg.Server)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shirou/gopsutil/v3/cpu"
|
||||||
|
"github.com/shirou/gopsutil/v3/load"
|
||||||
|
"github.com/shirou/gopsutil/v3/mem"
|
||||||
|
"github.com/wormhole/wormhole/internal/bridge"
|
||||||
|
"github.com/wormhole/wormhole/internal/cache"
|
||||||
|
"github.com/wormhole/wormhole/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Collector struct {
|
||||||
|
store *store.Store
|
||||||
|
cache *cache.Manager
|
||||||
|
bridge *bridge.Bridge
|
||||||
|
stop chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCollector(st *store.Store, c *cache.Manager, b *bridge.Bridge) *Collector {
|
||||||
|
return &Collector{
|
||||||
|
store: st,
|
||||||
|
cache: c,
|
||||||
|
bridge: b,
|
||||||
|
stop: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Collector) Start(flushInterval time.Duration) {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(flushInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
c.flushFlow()
|
||||||
|
case <-c.stop:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Collector) Stop() {
|
||||||
|
close(c.stop)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Collector) flushFlow() {
|
||||||
|
deltas := c.cache.DrainFlow()
|
||||||
|
for key, delta := range deltas {
|
||||||
|
parts := strings.SplitN(key, ":", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resourceType := parts[0]
|
||||||
|
resourceID, err := strconv.ParseUint(parts[1], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := c.store.UpsertFlowStat(resourceType, uint(resourceID), delta.Inlet, delta.Export); err != nil {
|
||||||
|
log.Printf("[metrics] flush flow stat: %v", err)
|
||||||
|
}
|
||||||
|
switch resourceType {
|
||||||
|
case "client":
|
||||||
|
_ = c.store.AddClientFlow(uint(resourceID), delta.Inlet, delta.Export)
|
||||||
|
case "tunnel":
|
||||||
|
_ = c.store.AddTunnelFlow(uint(resourceID), delta.Inlet, delta.Export)
|
||||||
|
case "host":
|
||||||
|
_ = c.store.AddHostFlow(uint(resourceID), delta.Inlet, delta.Export)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Collector) RecordRequestIP(resourceType string, resourceID uint, visitorIP, directIP string) {
|
||||||
|
if err := c.store.RecordRequestIP(resourceType, resourceID, visitorIP, directIP); err != nil {
|
||||||
|
log.Printf("[metrics] record request ip: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Collector) AddFlow(resourceType string, resourceID uint, clientID uint, inlet, export int64) {
|
||||||
|
c.cache.AddFlow(resourceType, resourceID, inlet, export)
|
||||||
|
if clientID > 0 {
|
||||||
|
c.cache.AddFlow("client", clientID, inlet, export)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Collector) Dashboard() (map[string]interface{}, error) {
|
||||||
|
stats, err := c.store.DashboardStats()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats["client_online"] = c.bridge.OnlineCount()
|
||||||
|
|
||||||
|
cpuPercent, _ := cpu.Percent(0, false)
|
||||||
|
memInfo, _ := mem.VirtualMemory()
|
||||||
|
loadInfo, _ := load.Avg()
|
||||||
|
|
||||||
|
var cpuVal float64
|
||||||
|
if len(cpuPercent) > 0 {
|
||||||
|
cpuVal = cpuPercent[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
topClients, _ := c.store.TopClientsByFlow(10)
|
||||||
|
topTunnels, _ := c.store.TopTunnelsByFlow(10)
|
||||||
|
topHosts, _ := c.store.TopHostsByFlow(10)
|
||||||
|
topIPs, _ := c.store.TopRequestIPs(10)
|
||||||
|
|
||||||
|
stats["cpu_percent"] = cpuVal
|
||||||
|
stats["mem_percent"] = memInfo.UsedPercent
|
||||||
|
stats["load1"] = loadInfo.Load1
|
||||||
|
stats["top_clients"] = topClients
|
||||||
|
stats["top_tunnels"] = topTunnels
|
||||||
|
stats["top_hosts"] = topHosts
|
||||||
|
stats["top_ips"] = topIPs
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package protocol
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MsgRegister byte = 1
|
||||||
|
MsgRegisterAck byte = 2
|
||||||
|
MsgPing byte = 3
|
||||||
|
MsgPong byte = 4
|
||||||
|
MsgOpenStream byte = 5
|
||||||
|
MsgStreamReady byte = 6
|
||||||
|
MsgClose byte = 7
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ConnTypeTCP = "tcp"
|
||||||
|
ConnTypeUDP = "udp"
|
||||||
|
ConnTypeHTTP = "http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RegisterPayload struct {
|
||||||
|
VerifyKey string `json:"verify_key"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegisterAckPayload struct {
|
||||||
|
ClientID uint `json:"client_id"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LinkMeta struct {
|
||||||
|
ConnType string `json:"conn_type"`
|
||||||
|
Target string `json:"target"`
|
||||||
|
RemoteAddr string `json:"remote_addr"`
|
||||||
|
StreamID uint32 `json:"stream_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteMessage(w io.Writer, msgType byte, payload interface{}) error {
|
||||||
|
var body []byte
|
||||||
|
var err error
|
||||||
|
if payload != nil {
|
||||||
|
body, err = json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
header := make([]byte, 5)
|
||||||
|
header[0] = msgType
|
||||||
|
binary.BigEndian.PutUint32(header[1:], uint32(len(body)))
|
||||||
|
if _, err := w.Write(header); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(body) > 0 {
|
||||||
|
_, err = w.Write(body)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadMessage(r io.Reader) (byte, []byte, error) {
|
||||||
|
header := make([]byte, 5)
|
||||||
|
if _, err := io.ReadFull(r, header); err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
msgType := header[0]
|
||||||
|
length := binary.BigEndian.Uint32(header[1:])
|
||||||
|
if length == 0 {
|
||||||
|
return msgType, nil, nil
|
||||||
|
}
|
||||||
|
if length > 1<<20 {
|
||||||
|
return 0, nil, fmt.Errorf("message too large: %d", length)
|
||||||
|
}
|
||||||
|
body := make([]byte, length)
|
||||||
|
if _, err := io.ReadFull(r, body); err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
return msgType, body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseRegister(body []byte) (*RegisterPayload, error) {
|
||||||
|
var p RegisterPayload
|
||||||
|
if err := json.Unmarshal(body, &p); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseLinkMeta(body []byte) (*LinkMeta, error) {
|
||||||
|
var p LinkMeta
|
||||||
|
if err := json.Unmarshal(body, &p); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/wormhole/wormhole/internal/auth"
|
||||||
|
"github.com/wormhole/wormhole/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// IPForwardReplace 覆盖 X-Forwarded-For / X-Real-IP 为访客真实 IP。
|
||||||
|
IPForwardReplace = "replace"
|
||||||
|
// IPForwardAppend 按反向代理规范追加 X-Forwarded-For(Nginx proxy_add_x_forwarded_for)。
|
||||||
|
IPForwardAppend = "append"
|
||||||
|
)
|
||||||
|
|
||||||
|
func normalizeIPForwardMode(mode string) string {
|
||||||
|
switch mode {
|
||||||
|
case IPForwardReplace, IPForwardAppend:
|
||||||
|
return mode
|
||||||
|
case "real": // 兼容旧值
|
||||||
|
return IPForwardReplace
|
||||||
|
case "proxy": // 兼容旧值
|
||||||
|
return IPForwardAppend
|
||||||
|
default:
|
||||||
|
return IPForwardReplace
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseHeaderMap(raw string) map[string]string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" || raw == "{}" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var m map[string]string
|
||||||
|
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeHeaderMaps(base, override map[string]string) map[string]string {
|
||||||
|
out := make(map[string]string)
|
||||||
|
for k, v := range base {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
for k, v := range override {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func effectiveIPMode(resourceMode, clientMode string) string {
|
||||||
|
if resourceMode != "" {
|
||||||
|
return normalizeIPForwardMode(resourceMode)
|
||||||
|
}
|
||||||
|
if clientMode != "" {
|
||||||
|
return normalizeIPForwardMode(clientMode)
|
||||||
|
}
|
||||||
|
return IPForwardReplace
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveProxyConfig(st *store.Store, clientID uint, resourceMode, resourceHeaders string) (mode string, headers map[string]string) {
|
||||||
|
client, _ := st.GetClientByID(clientID)
|
||||||
|
clientMode := ""
|
||||||
|
clientHeaders := ""
|
||||||
|
if client != nil {
|
||||||
|
clientMode = client.IPForwardMode
|
||||||
|
clientHeaders = client.CustomHeaders
|
||||||
|
}
|
||||||
|
mode = effectiveIPMode(resourceMode, clientMode)
|
||||||
|
headers = mergeHeaderMaps(parseHeaderMap(clientHeaders), parseHeaderMap(resourceHeaders))
|
||||||
|
return mode, headers
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostOnly(addr string) string {
|
||||||
|
if addr == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
host, _, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
return strings.Trim(addr, "[]")
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
func directClientIP(r *http.Request) string {
|
||||||
|
return hostOnly(r.RemoteAddr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func forwardIPForHTTP(r *http.Request, mode string) string {
|
||||||
|
mode = normalizeIPForwardMode(mode)
|
||||||
|
if mode == IPForwardAppend {
|
||||||
|
return directClientIP(r)
|
||||||
|
}
|
||||||
|
return auth.ExtractRemoteIP(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func forwardIPForTCP(clientAddr string, _ string) string {
|
||||||
|
return hostOnly(clientAddr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isForbiddenHeader(name string) bool {
|
||||||
|
switch strings.ToLower(name) {
|
||||||
|
case "connection", "transfer-encoding", "keep-alive", "proxy-connection",
|
||||||
|
"te", "trailer", "upgrade", "content-length", "host":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyOutboundHeaders(req *http.Request, r *http.Request, mode string, custom map[string]string) {
|
||||||
|
mode = normalizeIPForwardMode(mode)
|
||||||
|
req.Header.Del("X-Forwarded-For")
|
||||||
|
req.Header.Del("X-Real-IP")
|
||||||
|
req.Header.Del("Forwarded")
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case IPForwardAppend:
|
||||||
|
clientIP := directClientIP(r)
|
||||||
|
if clientIP == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
existing := strings.TrimSpace(r.Header.Get("X-Forwarded-For"))
|
||||||
|
xff := clientIP
|
||||||
|
if existing != "" {
|
||||||
|
xff = existing + ", " + clientIP
|
||||||
|
}
|
||||||
|
req.Header.Set("X-Forwarded-For", xff)
|
||||||
|
req.Header.Set("X-Real-IP", clientIP)
|
||||||
|
default:
|
||||||
|
visitorIP := auth.ExtractRemoteIP(r)
|
||||||
|
if visitorIP != "" {
|
||||||
|
req.Header.Set("X-Forwarded-For", visitorIP)
|
||||||
|
req.Header.Set("X-Real-IP", visitorIP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range custom {
|
||||||
|
if k == "" || isForbiddenHeader(k) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/wormhole/wormhole/internal/auth"
|
||||||
|
"github.com/wormhole/wormhole/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type httpTunnelServer struct {
|
||||||
|
manager *Manager
|
||||||
|
tunnel *store.Tunnel
|
||||||
|
srv *http.Server
|
||||||
|
stop chan struct{}
|
||||||
|
wg sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHTTPTunnelServer(m *Manager, t *store.Tunnel) Service {
|
||||||
|
return &httpTunnelServer{
|
||||||
|
manager: m,
|
||||||
|
tunnel: t,
|
||||||
|
stop: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *httpTunnelServer) ID() uint { return s.tunnel.ID }
|
||||||
|
|
||||||
|
func (s *httpTunnelServer) Start() error {
|
||||||
|
addr := net.JoinHostPort("0.0.0.0", itoa(s.tunnel.ListenPort))
|
||||||
|
s.srv = &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: http.HandlerFunc(s.handleHTTP),
|
||||||
|
}
|
||||||
|
log.Printf("[proxy] http tunnel %d (visitor auth) listening on %s -> %s", s.tunnel.ID, addr, s.tunnel.TargetAddr)
|
||||||
|
s.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer s.wg.Done()
|
||||||
|
if err := s.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Printf("[proxy] http tunnel %d error: %v", s.tunnel.ID, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *httpTunnelServer) handleHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t := s.tunnel
|
||||||
|
ip := auth.ExtractRemoteIP(r)
|
||||||
|
directIP := auth.DirectRemoteIP(r)
|
||||||
|
s.manager.metrics.RecordRequestIP("tunnel", t.ID, ip, directIP)
|
||||||
|
if !s.manager.security.Allow(directIP, t.ClientID, "tunnel", t.ID) {
|
||||||
|
http.Error(w, "forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.manager.resAuth.CheckHTTP(w, r, "tunnel", t.ID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.manager.proxyTunnelHTTP(w, r, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *httpTunnelServer) Stop() error {
|
||||||
|
close(s.stop)
|
||||||
|
if s.srv != nil {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = s.srv.Shutdown(ctx)
|
||||||
|
}
|
||||||
|
s.wg.Wait()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/wormhole/wormhole/internal/protocol"
|
||||||
|
"github.com/wormhole/wormhole/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type tcpServer struct {
|
||||||
|
manager *Manager
|
||||||
|
tunnel *store.Tunnel
|
||||||
|
ln net.Listener
|
||||||
|
stop chan struct{}
|
||||||
|
wg sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTCPServer(m *Manager, t *store.Tunnel) Service {
|
||||||
|
return &tcpServer{
|
||||||
|
manager: m,
|
||||||
|
tunnel: t,
|
||||||
|
stop: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tcpServer) ID() uint { return s.tunnel.ID }
|
||||||
|
|
||||||
|
func (s *tcpServer) Start() error {
|
||||||
|
addr := net.JoinHostPort("0.0.0.0", itoa(s.tunnel.ListenPort))
|
||||||
|
ln, err := net.Listen("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.ln = ln
|
||||||
|
log.Printf("[proxy] tcp tunnel %d listening on %s -> %s", s.tunnel.ID, addr, s.tunnel.TargetAddr)
|
||||||
|
go s.acceptLoop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tcpServer) acceptLoop() {
|
||||||
|
for {
|
||||||
|
conn, err := s.ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
select {
|
||||||
|
case <-s.stop:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.wg.Add(1)
|
||||||
|
go func(c net.Conn) {
|
||||||
|
defer s.wg.Done()
|
||||||
|
s.manager.handleTCPConnection(s.tunnel, c)
|
||||||
|
}(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tcpServer) Stop() error {
|
||||||
|
close(s.stop)
|
||||||
|
if s.ln != nil {
|
||||||
|
_ = s.ln.Close()
|
||||||
|
}
|
||||||
|
s.wg.Wait()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type udpSession struct {
|
||||||
|
stream net.Conn
|
||||||
|
last time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type udpServer struct {
|
||||||
|
manager *Manager
|
||||||
|
tunnel *store.Tunnel
|
||||||
|
conn *net.UDPConn
|
||||||
|
stop chan struct{}
|
||||||
|
mu sync.Mutex
|
||||||
|
sessions map[string]*udpSession
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUDPServer(m *Manager, t *store.Tunnel) Service {
|
||||||
|
return &udpServer{
|
||||||
|
manager: m,
|
||||||
|
tunnel: t,
|
||||||
|
stop: make(chan struct{}),
|
||||||
|
sessions: make(map[string]*udpSession),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *udpServer) ID() uint { return s.tunnel.ID }
|
||||||
|
|
||||||
|
func (s *udpServer) Start() error {
|
||||||
|
addr := net.JoinHostPort("0.0.0.0", itoa(s.tunnel.ListenPort))
|
||||||
|
udpAddr, err := net.ResolveUDPAddr("udp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
conn, err := net.ListenUDP("udp", udpAddr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.conn = conn
|
||||||
|
log.Printf("[proxy] udp tunnel %d listening on %s -> %s", s.tunnel.ID, addr, s.tunnel.TargetAddr)
|
||||||
|
go s.readLoop()
|
||||||
|
go s.sweeper()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *udpServer) readLoop() {
|
||||||
|
buf := make([]byte, 65535)
|
||||||
|
for {
|
||||||
|
n, remote, err := s.conn.ReadFromUDP(buf)
|
||||||
|
if err != nil {
|
||||||
|
select {
|
||||||
|
case <-s.stop:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ip := remote.IP.String()
|
||||||
|
s.manager.metrics.RecordRequestIP("tunnel", s.tunnel.ID, ip, ip)
|
||||||
|
if !s.manager.security.Allow(ip, s.tunnel.ClientID, "tunnel", s.tunnel.ID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := remote.String()
|
||||||
|
s.mu.Lock()
|
||||||
|
sess, ok := s.sessions[key]
|
||||||
|
if !ok {
|
||||||
|
sess = s.createSession(remote)
|
||||||
|
if sess == nil {
|
||||||
|
s.mu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.sessions[key] = sess
|
||||||
|
go s.runSession(key, sess, remote)
|
||||||
|
}
|
||||||
|
sess.last = time.Now()
|
||||||
|
stream := sess.stream
|
||||||
|
s.mu.Unlock()
|
||||||
|
_, _ = stream.Write(buf[:n])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *udpServer) createSession(remote *net.UDPAddr) *udpSession {
|
||||||
|
if !s.manager.bridge.IsOnline(s.tunnel.ClientID) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
mode, _ := resolveProxyConfig(s.manager.store, s.tunnel.ClientID, s.tunnel.IPForwardMode, s.tunnel.CustomHeaders)
|
||||||
|
meta := &protocol.LinkMeta{
|
||||||
|
ConnType: protocol.ConnTypeUDP,
|
||||||
|
Target: s.tunnel.TargetAddr,
|
||||||
|
RemoteAddr: forwardIPForTCP(remote.String(), mode),
|
||||||
|
}
|
||||||
|
stream, err := s.manager.bridge.OpenStream(s.tunnel.ClientID, meta)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &udpSession{stream: stream, last: time.Now()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *udpServer) runSession(key string, sess *udpSession, remote *net.UDPAddr) {
|
||||||
|
buf := make([]byte, 65535)
|
||||||
|
for {
|
||||||
|
n, err := sess.stream.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
_, _ = s.conn.WriteToUDP(buf[:n], remote)
|
||||||
|
inlet := int64(n)
|
||||||
|
s.manager.metrics.AddFlow("tunnel", s.tunnel.ID, s.tunnel.ClientID, inlet, 0)
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.sessions, key)
|
||||||
|
s.mu.Unlock()
|
||||||
|
_ = sess.stream.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *udpServer) sweeper() {
|
||||||
|
ticker := time.NewTicker(time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
now := time.Now()
|
||||||
|
s.mu.Lock()
|
||||||
|
for k, sess := range s.sessions {
|
||||||
|
if now.Sub(sess.last) > 120*time.Second {
|
||||||
|
_ = sess.stream.Close()
|
||||||
|
delete(s.sessions, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
case <-s.stop:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *udpServer) Stop() error {
|
||||||
|
close(s.stop)
|
||||||
|
if s.conn != nil {
|
||||||
|
_ = s.conn.Close()
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
for _, sess := range s.sessions {
|
||||||
|
_ = sess.stream.Close()
|
||||||
|
}
|
||||||
|
s.sessions = make(map[string]*udpSession)
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"github.com/wormhole/wormhole/internal/cache"
|
||||||
|
"github.com/wormhole/wormhole/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
cache *cache.Manager
|
||||||
|
store *store.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(c *cache.Manager, st *store.Store) *Service {
|
||||||
|
return &Service{cache: c, store: st}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Allow(ip string, clientID uint, resourceType string, resourceID uint) bool {
|
||||||
|
scopes := []cache.Scope{
|
||||||
|
{Kind: "global", ResourceID: 0},
|
||||||
|
{Kind: "client", ResourceID: clientID},
|
||||||
|
}
|
||||||
|
if resourceType == "host" || resourceType == "tunnel" {
|
||||||
|
scopes = append(scopes, cache.Scope{Kind: resourceType, ResourceID: resourceID})
|
||||||
|
}
|
||||||
|
return s.cache.AllowIP(ip, scopes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) BlockIP(ip, scope string, resourceID uint, remark string) error {
|
||||||
|
rule := &store.IPRule{
|
||||||
|
Scope: scope,
|
||||||
|
ResourceID: resourceID,
|
||||||
|
Type: "deny",
|
||||||
|
Pattern: ip,
|
||||||
|
PatternKind: "exact",
|
||||||
|
Priority: 100,
|
||||||
|
Remark: remark,
|
||||||
|
}
|
||||||
|
if err := s.store.CreateIPRule(rule); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.cache.ReloadIPRules(s.store)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DetectPatternKind(pattern string) string {
|
||||||
|
if _, _, err := net.ParseCIDR(pattern); err == nil {
|
||||||
|
return "cidr"
|
||||||
|
}
|
||||||
|
if len(pattern) > 0 && (pattern[0] == '^' || pattern[len(pattern)-1] == '$' || containsMeta(pattern)) {
|
||||||
|
return "regex"
|
||||||
|
}
|
||||||
|
return "exact"
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsMeta(s string) bool {
|
||||||
|
for _, c := range s {
|
||||||
|
switch c {
|
||||||
|
case '.', '*', '+', '?', '[', ']', '(', ')', '|', '\\':
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/wormhole/wormhole/internal/api"
|
||||||
|
"github.com/wormhole/wormhole/internal/auth"
|
||||||
|
"github.com/wormhole/wormhole/internal/bridge"
|
||||||
|
"github.com/wormhole/wormhole/internal/cache"
|
||||||
|
"github.com/wormhole/wormhole/internal/config"
|
||||||
|
"github.com/wormhole/wormhole/internal/metrics"
|
||||||
|
"github.com/wormhole/wormhole/internal/proxy"
|
||||||
|
"github.com/wormhole/wormhole/internal/security"
|
||||||
|
"github.com/wormhole/wormhole/internal/store"
|
||||||
|
webdist "github.com/wormhole/wormhole/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Run(args []string) {
|
||||||
|
fs := flag.NewFlagSet("server", flag.ExitOnError)
|
||||||
|
fs.Parse(args)
|
||||||
|
|
||||||
|
st, err := store.Open(config.DefaultDBPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("open store: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := st.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load settings: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("[server] settings loaded (%s)", cfg.Summary())
|
||||||
|
|
||||||
|
c := cache.NewManager()
|
||||||
|
if err := c.ReloadAll(st); err != nil {
|
||||||
|
log.Fatalf("reload cache: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
authSvc := auth.NewService(&cfg.Auth)
|
||||||
|
resAuth := auth.NewResourceAuth(c, st, &cfg.Auth)
|
||||||
|
sec := security.NewService(c, st)
|
||||||
|
b := bridge.New(c, st)
|
||||||
|
mc := metrics.NewCollector(st, c, b)
|
||||||
|
pm := proxy.NewManager(c, st, b, sec, resAuth, mc)
|
||||||
|
|
||||||
|
if err := b.Start(cfg.Server.BridgeAddr, cfg.Server.TLSCertFile, cfg.Server.TLSKeyFile); err != nil {
|
||||||
|
log.Fatalf("bridge start: %v", err)
|
||||||
|
}
|
||||||
|
mc.Start(cfg.Metrics.FlushInterval)
|
||||||
|
if err := pm.StartAll(); err != nil {
|
||||||
|
log.Printf("start tunnels: %v", err)
|
||||||
|
}
|
||||||
|
if err := pm.StartHTTPServer(cfg.Server.HTTPProxyPort, cfg.Server.HTTPSProxyPort, cfg.Server.TLSCertFile, cfg.Server.TLSKeyFile); err != nil {
|
||||||
|
log.Printf("start http proxy: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
apiServer := api.NewServer(cfg, st, c, authSvc, resAuth, sec, b, pm, mc)
|
||||||
|
router := apiServer.Router()
|
||||||
|
router.NoRoute(serveSPA())
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: cfg.Server.HTTPAddr,
|
||||||
|
Handler: router,
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
log.Printf("[server] http api on %s", cfg.Server.HTTPAddr)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Fatalf("http server: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
sig := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-sig
|
||||||
|
log.Println("[server] shutting down...")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
mc.Stop()
|
||||||
|
if err := pm.Shutdown(ctx); err != nil {
|
||||||
|
log.Printf("[server] proxy shutdown: %v", err)
|
||||||
|
}
|
||||||
|
_ = b.Shutdown()
|
||||||
|
if err := srv.Shutdown(ctx); err != nil {
|
||||||
|
log.Printf("[server] api shutdown: %v", err)
|
||||||
|
}
|
||||||
|
log.Println("[server] stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveSPA() gin.HandlerFunc {
|
||||||
|
distFS, err := fs.Sub(webdist.Dist, "dist")
|
||||||
|
if err != nil {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.String(http.StatusOK, "Wormhole API running. Build web dist for UI.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fileServer := http.FileServer(http.FS(distFS))
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
path := c.Request.URL.Path
|
||||||
|
if path == "/" {
|
||||||
|
path = "/index.html"
|
||||||
|
}
|
||||||
|
if f, err := distFS.Open(path[1:]); err == nil {
|
||||||
|
_ = f.Close()
|
||||||
|
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Request.URL.Path = "/"
|
||||||
|
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func hashAPIKey(key string) string {
|
||||||
|
sum := sha256.Sum256([]byte(key))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateAPIKeyPlain() (string, string, error) {
|
||||||
|
b := make([]byte, 24)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
plain := "wh_" + hex.EncodeToString(b)
|
||||||
|
prefix := plain
|
||||||
|
if len(prefix) > 12 {
|
||||||
|
prefix = prefix[:12]
|
||||||
|
}
|
||||||
|
return plain, prefix, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListAPIKeys(userID uint, isAdmin bool) ([]APIKey, error) {
|
||||||
|
var keys []APIKey
|
||||||
|
q := s.db.Order("id asc")
|
||||||
|
if !isAdmin {
|
||||||
|
q = q.Where("user_id = ?", userID)
|
||||||
|
}
|
||||||
|
return keys, q.Find(&keys).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateAPIKey(name string, userID uint) (*APIKey, string, error) {
|
||||||
|
if name == "" {
|
||||||
|
return nil, "", errors.New("name required")
|
||||||
|
}
|
||||||
|
plain, prefix, err := generateAPIKeyPlain()
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
key := &APIKey{
|
||||||
|
Name: name,
|
||||||
|
KeyPrefix: prefix,
|
||||||
|
KeyHash: hashAPIKey(plain),
|
||||||
|
UserID: userID,
|
||||||
|
Status: true,
|
||||||
|
}
|
||||||
|
if err := s.db.Create(key).Error; err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return key, plain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteAPIKey(id uint, userID uint, isAdmin bool) error {
|
||||||
|
var key APIKey
|
||||||
|
if err := s.db.First(&key, id).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !isAdmin && key.UserID != userID {
|
||||||
|
return fmt.Errorf("permission denied")
|
||||||
|
}
|
||||||
|
return s.db.Delete(&APIKey{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ValidateAPIKey(plain string) (*User, error) {
|
||||||
|
if plain == "" {
|
||||||
|
return nil, errors.New("empty api key")
|
||||||
|
}
|
||||||
|
hash := hashAPIKey(plain)
|
||||||
|
var key APIKey
|
||||||
|
if err := s.db.Where("key_hash = ? AND status = ?", hash, true).First(&key).Error; err != nil {
|
||||||
|
return nil, errors.New("invalid api key")
|
||||||
|
}
|
||||||
|
user, err := s.GetUserByID(key.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !user.Status {
|
||||||
|
return nil, errors.New("user disabled")
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ExportVersion = 1
|
||||||
|
|
||||||
|
type TunnelExportItem struct {
|
||||||
|
ClientID uint `json:"client_id"`
|
||||||
|
ClientName string `json:"client_name,omitempty"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
ListenIP string `json:"listen_ip"`
|
||||||
|
ListenPort int `json:"listen_port"`
|
||||||
|
TargetAddr string `json:"target_addr"`
|
||||||
|
IPForwardMode string `json:"ip_forward_mode"`
|
||||||
|
CustomHeaders string `json:"custom_headers"`
|
||||||
|
Status bool `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HostExportItem struct {
|
||||||
|
ClientID uint `json:"client_id"`
|
||||||
|
ClientName string `json:"client_name,omitempty"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
Scheme string `json:"scheme"`
|
||||||
|
TargetAddr string `json:"target_addr"`
|
||||||
|
AutoHTTPS bool `json:"auto_https"`
|
||||||
|
IPForwardMode string `json:"ip_forward_mode"`
|
||||||
|
CustomHeaders string `json:"custom_headers"`
|
||||||
|
Status bool `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TunnelExportDoc struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
ExportedAt time.Time `json:"exported_at"`
|
||||||
|
Items []TunnelExportItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HostExportDoc struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
ExportedAt time.Time `json:"exported_at"`
|
||||||
|
Items []HostExportItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TunnelToExportItem(t Tunnel, clientName string) TunnelExportItem {
|
||||||
|
return TunnelExportItem{
|
||||||
|
ClientID: t.ClientID,
|
||||||
|
ClientName: clientName,
|
||||||
|
Name: t.Name,
|
||||||
|
Mode: t.Mode,
|
||||||
|
ListenIP: t.ListenIP,
|
||||||
|
ListenPort: t.ListenPort,
|
||||||
|
TargetAddr: t.TargetAddr,
|
||||||
|
IPForwardMode: t.IPForwardMode,
|
||||||
|
CustomHeaders: t.CustomHeaders,
|
||||||
|
Status: t.Status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HostToExportItem(h Host, clientName string) HostExportItem {
|
||||||
|
return HostExportItem{
|
||||||
|
ClientID: h.ClientID,
|
||||||
|
ClientName: clientName,
|
||||||
|
Host: h.Host,
|
||||||
|
Location: h.Location,
|
||||||
|
Scheme: h.Scheme,
|
||||||
|
TargetAddr: h.TargetAddr,
|
||||||
|
AutoHTTPS: h.AutoHTTPS,
|
||||||
|
IPForwardMode: h.IPForwardMode,
|
||||||
|
CustomHeaders: h.CustomHeaders,
|
||||||
|
Status: h.Status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) FindTunnelByPort(port int) (*Tunnel, error) {
|
||||||
|
var t Tunnel
|
||||||
|
if err := s.db.Where("listen_port = ?", port).First(&t).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) FindHostByRouteGlobal(host, location string) (*Host, error) {
|
||||||
|
if location == "" {
|
||||||
|
location = "/"
|
||||||
|
}
|
||||||
|
var h Host
|
||||||
|
if err := s.db.Where("host = ? AND location = ?", host, location).First(&h).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &h, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetClientByName(name string) (*Client, error) {
|
||||||
|
var c Client
|
||||||
|
if err := s.db.Where("name = ?", name).First(&c).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) FindTunnelByListen(clientID uint, listenIP string, listenPort int) (*Tunnel, error) {
|
||||||
|
if listenIP == "" {
|
||||||
|
listenIP = "0.0.0.0"
|
||||||
|
}
|
||||||
|
var t Tunnel
|
||||||
|
if err := s.db.Where("client_id = ? AND listen_ip = ? AND listen_port = ?", clientID, listenIP, listenPort).First(&t).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) FindHostByRoute(clientID uint, host, location string) (*Host, error) {
|
||||||
|
if location == "" {
|
||||||
|
location = "/"
|
||||||
|
}
|
||||||
|
var h Host
|
||||||
|
if err := s.db.Where("client_id = ? AND host = ? AND location = ?", clientID, host, location).First(&h).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &h, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveImportClientID(s *Store, clientID uint, clientName string, ownerID uint, isAdmin bool) (uint, error) {
|
||||||
|
if clientID > 0 {
|
||||||
|
if err := ClientOwnerCheck(s, clientID, ownerID, isAdmin); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return clientID, nil
|
||||||
|
}
|
||||||
|
if clientName == "" {
|
||||||
|
return 0, errors.New("client_id or client_name required")
|
||||||
|
}
|
||||||
|
c, err := s.GetClientByName(clientName)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.New("client not found: " + clientName)
|
||||||
|
}
|
||||||
|
if err := ClientOwnerCheck(s, c.ID, ownerID, isAdmin); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return c.ID, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,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"`
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/wormhole/wormhole/internal/config"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const systemSettingsID = 1
|
||||||
|
|
||||||
|
type SystemSettings struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Payload string `gorm:"type:text;not null" json:"-"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ensureSystemSettings() error {
|
||||||
|
var row SystemSettings
|
||||||
|
err := s.db.First(&row, systemSettingsID).Error
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg := config.Default()
|
||||||
|
payload, err := json.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.db.Create(&SystemSettings{
|
||||||
|
ID: systemSettingsID,
|
||||||
|
Payload: string(payload),
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) LoadConfig() (*config.Config, error) {
|
||||||
|
if err := s.ensureSystemSettings(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var row SystemSettings
|
||||||
|
if err := s.db.First(&row, systemSettingsID).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg := config.Default()
|
||||||
|
if err := json.Unmarshal([]byte(row.Payload), cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Database.Path = config.DefaultDBPath
|
||||||
|
cfg.Normalize()
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SaveConfig(cfg *config.Config) error {
|
||||||
|
if err := s.ensureSystemSettings(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg.Database.Path = config.DefaultDBPath
|
||||||
|
cfg.Normalize()
|
||||||
|
payload, err := json.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.db.Model(&SystemSettings{}).Where("id = ?", systemSettingsID).Updates(map[string]interface{}{
|
||||||
|
"payload": string(payload),
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
@@ -0,0 +1,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
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import "gorm.io/gorm"
|
||||||
|
|
||||||
|
func (s *Store) ListVisitors() ([]User, error) {
|
||||||
|
var users []User
|
||||||
|
err := s.db.Where("role = ?", "visitor").Order("id asc").Find(&users).Error
|
||||||
|
return users, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListResourceVisitorIDs(resourceType string, resourceID uint) ([]uint, error) {
|
||||||
|
var bindings []ResourceVisitor
|
||||||
|
err := s.db.Where("resource_type = ? AND resource_id = ?", resourceType, resourceID).
|
||||||
|
Order("user_id asc").Find(&bindings).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids := make([]uint, len(bindings))
|
||||||
|
for i, b := range bindings {
|
||||||
|
ids[i] = b.UserID
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SetResourceVisitors(resourceType string, resourceID uint, userIDs []uint) error {
|
||||||
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Where("resource_type = ? AND resource_id = ?", resourceType, resourceID).
|
||||||
|
Delete(&ResourceVisitor{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, uid := range userIDs {
|
||||||
|
rv := ResourceVisitor{
|
||||||
|
ResourceType: resourceType,
|
||||||
|
ResourceID: resourceID,
|
||||||
|
UserID: uid,
|
||||||
|
}
|
||||||
|
if err := tx.Create(&rv).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteResourceVisitors(resourceType string, resourceID uint) error {
|
||||||
|
return s.db.Where("resource_type = ? AND resource_id = ?", resourceType, resourceID).
|
||||||
|
Delete(&ResourceVisitor{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListAllResourceVisitors() ([]ResourceVisitor, error) {
|
||||||
|
var items []ResourceVisitor
|
||||||
|
err := s.db.Order("resource_type, resource_id, user_id").Find(&items).Error
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const MaxVisitorAccessLogsPerUser = 200
|
||||||
|
|
||||||
|
func (s *Store) AppendVisitorAccessLog(entry *VisitorAccessLog) error {
|
||||||
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Create(entry).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var keepIDs []uint
|
||||||
|
if err := tx.Model(&VisitorAccessLog{}).Where("user_id = ?", entry.UserID).
|
||||||
|
Order("id desc").Limit(MaxVisitorAccessLogsPerUser).Pluck("id", &keepIDs).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(keepIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return tx.Where("user_id = ? AND id NOT IN ?", entry.UserID, keepIDs).
|
||||||
|
Delete(&VisitorAccessLog{}).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListVisitorAccessLogs(userID uint, limit int) ([]VisitorAccessLog, error) {
|
||||||
|
if limit <= 0 || limit > MaxVisitorAccessLogsPerUser {
|
||||||
|
limit = MaxVisitorAccessLogsPerUser
|
||||||
|
}
|
||||||
|
var items []VisitorAccessLog
|
||||||
|
err := s.db.Where("user_id = ?", userID).
|
||||||
|
Order("id desc").Limit(limit).Find(&items).Error
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteVisitorAccessLogs(userID uint) error {
|
||||||
|
return s.db.Where("user_id = ?", userID).Delete(&VisitorAccessLog{}).Error
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package version
|
||||||
|
|
||||||
|
const Version = "0.1.0"
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
-- Wormhole schema (also applied via GORM AutoMigrate)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
|
status INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME,
|
||||||
|
updated_at DATETIME
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS clients (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
verify_key TEXT NOT NULL UNIQUE,
|
||||||
|
owner_user_id INTEGER NOT NULL,
|
||||||
|
rate_limit INTEGER DEFAULT 0,
|
||||||
|
max_conn INTEGER DEFAULT 0,
|
||||||
|
expire_at DATETIME,
|
||||||
|
status INTEGER NOT NULL DEFAULT 1,
|
||||||
|
inlet_flow INTEGER DEFAULT 0,
|
||||||
|
export_flow INTEGER DEFAULT 0,
|
||||||
|
created_at DATETIME,
|
||||||
|
updated_at DATETIME
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tunnels (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
client_id INTEGER NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
mode TEXT NOT NULL,
|
||||||
|
listen_ip TEXT DEFAULT '0.0.0.0',
|
||||||
|
listen_port INTEGER NOT NULL,
|
||||||
|
target_addr TEXT NOT NULL,
|
||||||
|
status INTEGER NOT NULL DEFAULT 1,
|
||||||
|
run_status INTEGER NOT NULL DEFAULT 0,
|
||||||
|
inlet_flow INTEGER DEFAULT 0,
|
||||||
|
export_flow INTEGER DEFAULT 0,
|
||||||
|
created_at DATETIME,
|
||||||
|
updated_at DATETIME
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS hosts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
client_id INTEGER NOT NULL,
|
||||||
|
host TEXT NOT NULL,
|
||||||
|
location TEXT DEFAULT '/',
|
||||||
|
scheme TEXT DEFAULT 'all',
|
||||||
|
target_addr TEXT NOT NULL,
|
||||||
|
cert_path TEXT,
|
||||||
|
key_path TEXT,
|
||||||
|
auto_https INTEGER DEFAULT 0,
|
||||||
|
status INTEGER NOT NULL DEFAULT 1,
|
||||||
|
inlet_flow INTEGER DEFAULT 0,
|
||||||
|
export_flow INTEGER DEFAULT 0,
|
||||||
|
created_at DATETIME,
|
||||||
|
updated_at DATETIME
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ip_rules (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
scope TEXT NOT NULL,
|
||||||
|
resource_id INTEGER DEFAULT 0,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
pattern TEXT NOT NULL,
|
||||||
|
pattern_kind TEXT NOT NULL DEFAULT 'exact',
|
||||||
|
priority INTEGER DEFAULT 0,
|
||||||
|
remark TEXT,
|
||||||
|
created_at DATETIME,
|
||||||
|
updated_at DATETIME
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS auth_policies (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
resource_type TEXT NOT NULL,
|
||||||
|
resource_id INTEGER NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
password TEXT,
|
||||||
|
callback_url TEXT,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
ttl_seconds INTEGER DEFAULT 7200,
|
||||||
|
created_at DATETIME,
|
||||||
|
updated_at DATETIME
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_stats (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
resource_type TEXT NOT NULL,
|
||||||
|
resource_id INTEGER NOT NULL,
|
||||||
|
inlet_bytes INTEGER DEFAULT 0,
|
||||||
|
export_bytes INTEGER DEFAULT 0,
|
||||||
|
updated_at DATETIME,
|
||||||
|
UNIQUE(resource_type, resource_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS request_ips (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
resource_type TEXT NOT NULL,
|
||||||
|
resource_id INTEGER NOT NULL,
|
||||||
|
ip TEXT NOT NULL,
|
||||||
|
hit_count INTEGER DEFAULT 0,
|
||||||
|
last_seen_at DATETIME,
|
||||||
|
UNIQUE(resource_type, resource_id, ip)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
expires_at DATETIME,
|
||||||
|
created_at DATETIME
|
||||||
|
);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Proxy headers and IP forward mode for clients, tunnels, hosts
|
||||||
|
|
||||||
|
ALTER TABLE clients ADD COLUMN ip_forward_mode TEXT DEFAULT 'real';
|
||||||
|
ALTER TABLE clients ADD COLUMN custom_headers TEXT DEFAULT '';
|
||||||
|
|
||||||
|
ALTER TABLE tunnels ADD COLUMN ip_forward_mode TEXT DEFAULT 'real';
|
||||||
|
ALTER TABLE tunnels ADD COLUMN custom_headers TEXT DEFAULT '';
|
||||||
|
|
||||||
|
ALTER TABLE hosts ADD COLUMN ip_forward_mode TEXT DEFAULT 'real';
|
||||||
|
ALTER TABLE hosts ADD COLUMN custom_headers TEXT DEFAULT '';
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- System settings stored in DB (also via GORM AutoMigrate)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS system_settings (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
updated_at DATETIME
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
registry=https://registry.npmmirror.com
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed dist/*
|
||||||
|
var Dist embed.FS
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "wormhole-web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
|
"axios": "^1.7.7",
|
||||||
|
"echarts": "^5.5.1",
|
||||||
|
"element-plus": "^2.8.4",
|
||||||
|
"vue": "^3.5.11",
|
||||||
|
"vue-i18n": "^9.14.4",
|
||||||
|
"vue-router": "^4.4.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.1.4",
|
||||||
|
"vite": "^5.4.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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 |
@@ -0,0 +1,15 @@
|
|||||||
|
<template>
|
||||||
|
<el-config-provider :locale="epLocale">
|
||||||
|
<router-view />
|
||||||
|
</el-config-provider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||||
|
import enLocale from 'element-plus/dist/locale/en.mjs'
|
||||||
|
|
||||||
|
const { locale } = useI18n()
|
||||||
|
const epLocale = computed(() => (locale.value === 'en' ? enLocale : zhCn))
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const api = axios.create({ baseURL: '/api' })
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(res) => res,
|
||||||
|
(err) => {
|
||||||
|
if (err.response?.status === 401) {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
localStorage.removeItem('user')
|
||||||
|
if (!window.location.pathname.includes('/login')) {
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(err)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default api
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-card">
|
||||||
|
<div v-if="title || $slots.header" class="app-card__header">
|
||||||
|
<slot name="header">
|
||||||
|
<h3 class="app-card__title">{{ title }}</h3>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
<div :class="padded ? 'app-card__body--padded' : ''">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
padded: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,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>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="root" class="chart-box" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
option: { type: Object, default: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
const root = ref(null)
|
||||||
|
let chart
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
chart?.resize()
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!chart || !props.option) return
|
||||||
|
chart.setOption(props.option, { notMerge: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
chart = echarts.init(root.value)
|
||||||
|
render()
|
||||||
|
window.addEventListener('resize', resize)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', resize)
|
||||||
|
chart?.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.option, render, { deep: true })
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<template>
|
||||||
|
<el-select
|
||||||
|
:model-value="modelValue"
|
||||||
|
:disabled="disabled"
|
||||||
|
filterable
|
||||||
|
:placeholder="t('clientSelect.placeholder')"
|
||||||
|
class="w-full"
|
||||||
|
@update:model-value="$emit('update:modelValue', $event)"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="c in clients"
|
||||||
|
:key="c.id"
|
||||||
|
:label="clientLabel(c)"
|
||||||
|
:value="c.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
modelValue: { type: Number, default: null },
|
||||||
|
clients: { type: Array, default: () => [] },
|
||||||
|
disabled: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
function clientLabel(c) {
|
||||||
|
const status = c.online ? t('common.online') : t('common.offline')
|
||||||
|
return t('clientSelect.label', { name: c.name, id: c.id, status })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<el-button @click="handleExport" :loading="exporting">{{ t('common.export') }}</el-button>
|
||||||
|
<el-button @click="openImport">{{ t('common.import') }}</el-button>
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
class="hidden-input"
|
||||||
|
@change="onFileChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-dialog v-model="importVisible" :title="t('importExport.importTitle')" width="480px" destroy-on-close>
|
||||||
|
<el-form label-width="100px">
|
||||||
|
<el-form-item :label="t('importExport.importMode')">
|
||||||
|
<el-select v-model="importMode">
|
||||||
|
<el-option :label="t('importExport.upsert')" value="upsert" />
|
||||||
|
<el-option :label="t('importExport.skip')" value="skip" />
|
||||||
|
<el-option :label="t('importExport.create')" value="create" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="t('importExport.pending')">
|
||||||
|
<span>{{ t('importExport.pendingCount', { n: pendingItems.length }) }}</span>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="importVisible = false">{{ t('common.cancel') }}</el-button>
|
||||||
|
<el-button type="primary" :loading="importing" @click="confirmImport">{{ t('importExport.startImport') }}</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { exportResource, importResource, parseImportFile } from '@/utils/importExport'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
resource: { type: String, required: true },
|
||||||
|
exportFilename: { type: String, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['done'])
|
||||||
|
|
||||||
|
const exporting = ref(false)
|
||||||
|
const importing = ref(false)
|
||||||
|
const importVisible = ref(false)
|
||||||
|
const importMode = ref('upsert')
|
||||||
|
const pendingItems = ref([])
|
||||||
|
const fileInput = ref(null)
|
||||||
|
|
||||||
|
async function handleExport() {
|
||||||
|
exporting.value = true
|
||||||
|
try {
|
||||||
|
const data = await exportResource(`/${props.resource}`, props.exportFilename)
|
||||||
|
ElMessage.success(t('importExport.exportSuccess', { n: data.items?.length ?? 0 }))
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.response?.data?.error || t('message.exportFailed'))
|
||||||
|
} finally {
|
||||||
|
exporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openImport() {
|
||||||
|
fileInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onFileChange(e) {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
e.target.value = ''
|
||||||
|
if (!file) return
|
||||||
|
try {
|
||||||
|
pendingItems.value = await parseImportFile(file)
|
||||||
|
importMode.value = 'upsert'
|
||||||
|
importVisible.value = true
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error(err.message || t('importExport.parseFailed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmImport() {
|
||||||
|
importing.value = true
|
||||||
|
try {
|
||||||
|
const result = await importResource(`/${props.resource}`, importMode.value, pendingItems.value)
|
||||||
|
const msg = t('importExport.importDone', {
|
||||||
|
created: result.created,
|
||||||
|
updated: result.updated,
|
||||||
|
skipped: result.skipped,
|
||||||
|
failed: result.failed,
|
||||||
|
})
|
||||||
|
if (result.failed > 0) {
|
||||||
|
ElMessage.warning(msg)
|
||||||
|
if (result.errors?.length) {
|
||||||
|
console.warn('import errors:', result.errors)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ElMessage.success(msg)
|
||||||
|
}
|
||||||
|
importVisible.value = false
|
||||||
|
emit('done')
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.response?.data?.error || t('message.importFailed'))
|
||||||
|
} finally {
|
||||||
|
importing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">{{ title }}</h1>
|
||||||
|
<p v-if="description" class="page-desc">{{ description }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="$slots.actions" class="page-actions">
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
title: { type: String, required: true },
|
||||||
|
description: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<template>
|
||||||
|
<div class="proxy-headers-fields">
|
||||||
|
<el-form-item :label="t('ipForward.ipForward')">
|
||||||
|
<el-radio-group v-model="ipMode">
|
||||||
|
<el-radio label="replace">{{ ipForwardModeLabel('replace', t) }}</el-radio>
|
||||||
|
<el-radio label="append">{{ ipForwardModeLabel('append', t) }}</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
<div class="form-hint">{{ modeHint }}</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="t('ipForward.customHeaders')">
|
||||||
|
<div class="header-list">
|
||||||
|
<div v-for="(row, idx) in headerRows" :key="idx" class="header-row">
|
||||||
|
<el-input v-model="row.key" :placeholder="t('ipForward.headerName')" @input="emitHeaders" />
|
||||||
|
<el-input v-model="row.value" :placeholder="t('ipForward.headerValue')" @input="emitHeaders" />
|
||||||
|
<el-button link type="danger" @click="removeRow(idx)">{{ t('common.delete') }}</el-button>
|
||||||
|
</div>
|
||||||
|
<el-button size="small" @click="addRow">{{ t('ipForward.addHeader') }}</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { ipForwardModeHint, ipForwardModeLabel, normalizeIPForwardMode } from '@/utils/ipForward'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
ipForwardMode: { type: String, default: 'replace' },
|
||||||
|
customHeaders: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:ipForwardMode', 'update:customHeaders'])
|
||||||
|
|
||||||
|
const headerRows = ref([])
|
||||||
|
|
||||||
|
function parseHeaders(raw) {
|
||||||
|
if (!raw || raw === '{}') return []
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(raw)
|
||||||
|
return Object.entries(obj).map(([key, value]) => ({ key, value: String(value) }))
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeHeaders(rows) {
|
||||||
|
const obj = {}
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = row.key?.trim()
|
||||||
|
if (!key) continue
|
||||||
|
obj[key] = row.value ?? ''
|
||||||
|
}
|
||||||
|
return Object.keys(obj).length ? JSON.stringify(obj) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncFromProps() {
|
||||||
|
headerRows.value = parseHeaders(props.customHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.customHeaders, syncFromProps, { immediate: true })
|
||||||
|
|
||||||
|
const ipMode = computed({
|
||||||
|
get() {
|
||||||
|
return normalizeIPForwardMode(props.ipForwardMode)
|
||||||
|
},
|
||||||
|
set(v) {
|
||||||
|
emit('update:ipForwardMode', v)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const modeHint = computed(() => ipForwardModeHint(ipMode.value, t))
|
||||||
|
|
||||||
|
function emitHeaders() {
|
||||||
|
emit('update:customHeaders', serializeHeaders(headerRows.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRow() {
|
||||||
|
headerRows.value.push({ key: '', value: '' })
|
||||||
|
emitHeaders()
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRow(idx) {
|
||||||
|
headerRows.value.splice(idx, 1)
|
||||||
|
emitHeaders()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.header-list {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.header-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr auto;
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,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>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<template>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-card__label">{{ label }}</div>
|
||||||
|
<div class="stat-card__value">{{ value }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
label: { type: String, required: true },
|
||||||
|
value: { type: [String, Number], required: true },
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<template>
|
||||||
|
<div class="visitor-auth">
|
||||||
|
<el-divider content-position="left">{{ t('visitorAuth.title') }}</el-divider>
|
||||||
|
<el-form-item :label="t('visitorAuth.enable')">
|
||||||
|
<el-switch v-model="enabled" />
|
||||||
|
</el-form-item>
|
||||||
|
<template v-if="enabled">
|
||||||
|
<el-form-item :label="t('visitorAuth.allowVisitors')">
|
||||||
|
<el-radio-group v-model="bindMode">
|
||||||
|
<el-radio value="all">{{ t('visitorAuth.allVisitors') }}</el-radio>
|
||||||
|
<el-radio value="selected">{{ t('visitorAuth.selectedVisitors') }}</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="bindMode === 'selected'" :label="t('visitorAuth.selectVisitors')">
|
||||||
|
<el-select v-model="visitorIds" multiple filterable :placeholder="t('visitorAuth.selectVisitors')">
|
||||||
|
<el-option v-for="v in visitors" :key="v.id" :label="v.username" :value="v.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="t('visitorAuth.ttl')">
|
||||||
|
<el-input-number v-model="ttlSeconds" :min="300" :max="604800" :step="3600" class="w-full" />
|
||||||
|
<div class="form-hint">{{ t('visitorAuth.ttlHint') }}</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-alert
|
||||||
|
v-if="tunnelHint"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
:title="t('visitorAuth.tunnelHint')"
|
||||||
|
class="u-mb-4"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visitorAuthEnabled: Boolean,
|
||||||
|
visitorBindMode: { type: String, default: 'all' },
|
||||||
|
visitorTtlSeconds: { type: Number, default: 7200 },
|
||||||
|
visitorIds: { type: Array, default: () => [] },
|
||||||
|
visitors: { type: Array, default: () => [] },
|
||||||
|
tunnelHint: Boolean,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'update:visitorAuthEnabled',
|
||||||
|
'update:visitorBindMode',
|
||||||
|
'update:visitorTtlSeconds',
|
||||||
|
'update:visitorIds',
|
||||||
|
])
|
||||||
|
|
||||||
|
const enabled = computed({
|
||||||
|
get: () => props.visitorAuthEnabled,
|
||||||
|
set: (v) => emit('update:visitorAuthEnabled', v),
|
||||||
|
})
|
||||||
|
const bindMode = computed({
|
||||||
|
get: () => props.visitorBindMode || 'all',
|
||||||
|
set: (v) => emit('update:visitorBindMode', v),
|
||||||
|
})
|
||||||
|
const ttlSeconds = computed({
|
||||||
|
get: () => props.visitorTtlSeconds || 7200,
|
||||||
|
set: (v) => emit('update:visitorTtlSeconds', v),
|
||||||
|
})
|
||||||
|
const visitorIds = computed({
|
||||||
|
get: () => props.visitorIds || [],
|
||||||
|
set: (v) => emit('update:visitorIds', v),
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { formatChartTime } from '@/utils/datetime'
|
||||||
|
|
||||||
|
const MAX_POINTS = 36
|
||||||
|
|
||||||
|
export function useMetricHistory() {
|
||||||
|
const labels = ref([])
|
||||||
|
const cpu = ref([])
|
||||||
|
const memory = ref([])
|
||||||
|
const inlet = ref([])
|
||||||
|
const exportFlow = ref([])
|
||||||
|
|
||||||
|
function trim(arr) {
|
||||||
|
return arr.length > MAX_POINTS ? arr.slice(-MAX_POINTS) : arr
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(snapshot) {
|
||||||
|
labels.value = trim([...labels.value, formatChartTime()])
|
||||||
|
cpu.value = trim([...cpu.value, Number(snapshot.cpu_percent?.toFixed?.(1) ?? 0)])
|
||||||
|
memory.value = trim([...memory.value, Number(snapshot.mem_percent?.toFixed?.(1) ?? 0)])
|
||||||
|
inlet.value = trim([...inlet.value, snapshot.inlet_flow ?? 0])
|
||||||
|
exportFlow.value = trim([...exportFlow.value, snapshot.export_flow ?? 0])
|
||||||
|
}
|
||||||
|
|
||||||
|
return { labels, cpu, memory, inlet, exportFlow, record }
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
import zhCN from '@/locales/zh-CN'
|
||||||
|
import en from '@/locales/en'
|
||||||
|
|
||||||
|
const LOCALE_KEY = 'wormhole-locale'
|
||||||
|
|
||||||
|
function detectLocale() {
|
||||||
|
const saved = localStorage.getItem(LOCALE_KEY)
|
||||||
|
if (saved === 'zh-CN' || saved === 'en') return saved
|
||||||
|
const lang = navigator.language || ''
|
||||||
|
return lang.startsWith('zh') ? 'zh-CN' : 'en'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: detectLocale(),
|
||||||
|
fallbackLocale: 'zh-CN',
|
||||||
|
messages: { 'zh-CN': zhCN, en },
|
||||||
|
})
|
||||||
|
|
||||||
|
export function setLocale(locale) {
|
||||||
|
i18n.global.locale.value = locale
|
||||||
|
localStorage.setItem(LOCALE_KEY, locale)
|
||||||
|
document.documentElement.lang = locale === 'zh-CN' ? 'zh-CN' : 'en'
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocale(i18n.global.locale.value)
|
||||||
|
|
||||||
|
export default i18n
|
||||||
@@ -0,0 +1,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>
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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: '已加入黑名单',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||||
|
import enLocale from 'element-plus/dist/locale/en.mjs'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import '@/styles/theme.css'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import i18n from '@/i18n'
|
||||||
|
|
||||||
|
const epLocales = { 'zh-CN': zhCn, en: enLocale }
|
||||||
|
|
||||||
|
createApp(App)
|
||||||
|
.use(ElementPlus, { locale: epLocales[i18n.global.locale.value] })
|
||||||
|
.use(i18n)
|
||||||
|
.use(router)
|
||||||
|
.mount('#app')
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -0,0 +1,557 @@
|
|||||||
|
:root {
|
||||||
|
--background: #f4f4f5;
|
||||||
|
--foreground: #18181b;
|
||||||
|
--card: #ffffff;
|
||||||
|
--card-foreground: #18181b;
|
||||||
|
--primary: #0f7a62;
|
||||||
|
--primary-hover: #0d6b56;
|
||||||
|
--primary-foreground: #fafafa;
|
||||||
|
--muted: #f4f4f5;
|
||||||
|
--muted-foreground: #71717a;
|
||||||
|
--border: #e4e4e7;
|
||||||
|
--input: #e4e4e7;
|
||||||
|
--destructive: #dc2626;
|
||||||
|
--success: #047857;
|
||||||
|
--warning: #b45309;
|
||||||
|
--danger: #dc2626;
|
||||||
|
--info: #a1a1aa;
|
||||||
|
--sidebar: rgba(250, 250, 250, 0.85);
|
||||||
|
--sidebar-foreground: #18181b;
|
||||||
|
--sidebar-accent: #f4f4f5;
|
||||||
|
--sidebar-border: #e4e4e7;
|
||||||
|
--sidebar-width: 15rem;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--space-1: 4px;
|
||||||
|
--space-2: 8px;
|
||||||
|
--space-3: 12px;
|
||||||
|
--space-4: 16px;
|
||||||
|
--space-5: 20px;
|
||||||
|
--space-6: 24px;
|
||||||
|
--space-8: 32px;
|
||||||
|
|
||||||
|
--el-color-primary: #0f7a62;
|
||||||
|
--el-color-primary-light-3: #4da08c;
|
||||||
|
--el-color-primary-light-5: #76b8a8;
|
||||||
|
--el-color-primary-light-7: #a0d0c4;
|
||||||
|
--el-color-primary-light-8: #b8dcd2;
|
||||||
|
--el-color-primary-light-9: #d0e8e1;
|
||||||
|
--el-color-primary-dark-2: #0c624e;
|
||||||
|
--el-color-success: #047857;
|
||||||
|
--el-color-warning: #b45309;
|
||||||
|
--el-color-danger: #dc2626;
|
||||||
|
--el-color-info: #a1a1aa;
|
||||||
|
--el-border-radius-base: 0.5rem;
|
||||||
|
--el-border-color: #e4e4e7;
|
||||||
|
--el-bg-color-page: #f4f4f5;
|
||||||
|
--el-text-color-primary: #18181b;
|
||||||
|
--el-text-color-regular: #3f3f46;
|
||||||
|
--el-text-color-secondary: #71717a;
|
||||||
|
--el-font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body, #app {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: var(--el-font-family);
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--sidebar);
|
||||||
|
border-right: 1px solid var(--sidebar-border);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-5) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--primary-foreground);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-name {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-group-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
padding: var(--space-4) var(--space-4) var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
margin: 0 var(--space-2);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover { background: var(--sidebar-accent); }
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
background: var(--sidebar-accent);
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-area {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
height: 52px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 var(--space-6);
|
||||||
|
background: var(--card);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-6);
|
||||||
|
background: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 1400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
margin: 0 0 var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-desc {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: var(--space-4);
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card__label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card__value {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page > .app-card:last-child,
|
||||||
|
.page > .content-grid:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card__header {
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card__title {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card__body--padded { padding: var(--space-5); }
|
||||||
|
|
||||||
|
.content-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-grid--2-1 { grid-template-columns: 1.4fr 1fr; }
|
||||||
|
.content-grid--2 { grid-template-columns: 1fr 1fr; }
|
||||||
|
.content-grid--3 { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
|
||||||
|
.card-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-4) var(--space-5) 0;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-toolbar--desc {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-pager {
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer-hint {
|
||||||
|
padding: 0 var(--space-5) var(--space-3);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer-hint + .card-pager {
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-inset-block {
|
||||||
|
margin: 0 var(--space-5) var(--space-4);
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--muted);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-bar {
|
||||||
|
padding: 0 var(--space-5);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-bar .el-tabs__header { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.page-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint--flush { margin-top: 0; }
|
||||||
|
|
||||||
|
.u-mt-3 { margin-top: var(--space-3); }
|
||||||
|
.u-mt-4 { margin-top: var(--space-4); }
|
||||||
|
.u-mb-3 { margin-bottom: var(--space-3); }
|
||||||
|
.u-mb-4 { margin-bottom: var(--space-4); }
|
||||||
|
.u-mb-6 { margin-bottom: var(--space-6); }
|
||||||
|
.text-center { text-align: center; }
|
||||||
|
.w-full { width: 100%; }
|
||||||
|
|
||||||
|
code.inline-code {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0 var(--space-1);
|
||||||
|
background: var(--muted);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title:not(:first-child) { margin-top: var(--space-6); }
|
||||||
|
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--background);
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-wrap { max-width: 400px; width: 100%; text-align: center; }
|
||||||
|
|
||||||
|
.login-brand-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--primary-foreground);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--space-8);
|
||||||
|
margin-top: var(--space-6);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-good { color: #047857; }
|
||||||
|
.status-warn { color: #b45309; }
|
||||||
|
.status-bad { color: #dc2626; }
|
||||||
|
.status-muted { color: var(--muted-foreground); }
|
||||||
|
|
||||||
|
.toolbar-select { width: 120px; }
|
||||||
|
.toolbar-select--country { width: 160px; }
|
||||||
|
.toolbar-select--wide { width: 280px; }
|
||||||
|
.toolbar-input { width: 200px; }
|
||||||
|
.toolbar-input--wide { width: 260px; }
|
||||||
|
.toolbar-input--grow { flex: 1; min-width: 200px; }
|
||||||
|
.table-cell-select { width: 100%; min-width: 200px; }
|
||||||
|
|
||||||
|
.el-form .el-select { width: 100%; }
|
||||||
|
.el-form .el-input-number { width: 100%; }
|
||||||
|
|
||||||
|
.app-card .el-table {
|
||||||
|
--el-table-header-bg-color: var(--muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card .el-table--small .el-table__cell {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card .el-table--small th.el-table__cell {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card .el-table--small td.el-table__cell {
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card .el-table--small .el-table__row {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card .el-table .el-button.is-link {
|
||||||
|
padding: 0;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card .el-table .cell {
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-scroll--sm { max-height: 320px; }
|
||||||
|
.table-scroll--md { max-height: 360px; }
|
||||||
|
.table-scroll--lg { max-height: 480px; }
|
||||||
|
|
||||||
|
.card-toolbar .toolbar-input--grow {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-pager .el-pagination {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-block p {
|
||||||
|
margin: 0 0 var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-block p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-block .label {
|
||||||
|
display: inline-block;
|
||||||
|
width: 5rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-block code {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-list li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-1) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-list__mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-list__ok { color: var(--el-color-success); }
|
||||||
|
.meta-list__warn { color: var(--el-color-warning); }
|
||||||
|
|
||||||
|
.chart-box {
|
||||||
|
height: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #e4e4e7 transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: #e4e4e7;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form {
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-dialog {
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.stat-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.content-grid--2-1,
|
||||||
|
.content-grid--2,
|
||||||
|
.content-grid--3 { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.stat-grid { grid-template-columns: 1fr; }
|
||||||
|
.main-content { padding: var(--space-4); }
|
||||||
|
.page-header { flex-direction: column; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/** §6.16 ECharts 品牌色与默认 grid */
|
||||||
|
export const CHART_COLORS = ['#0f7a62', '#71717a', '#4da08c', '#76b8a8', '#a0d0c4']
|
||||||
|
|
||||||
|
const AXIS_LABEL = { color: '#71717a', fontSize: 12 }
|
||||||
|
const AXIS_LINE = { lineStyle: { color: '#e4e4e7' } }
|
||||||
|
const SPLIT_LINE = { lineStyle: { color: '#e4e4e7' } }
|
||||||
|
|
||||||
|
export function buildLineChartOption({ labels, series, legend = true, yAxisFormatter }) {
|
||||||
|
return {
|
||||||
|
color: CHART_COLORS,
|
||||||
|
grid: { left: 40, right: 16, top: legend ? 36 : 16, bottom: 24 },
|
||||||
|
legend: legend
|
||||||
|
? { top: 0, textStyle: { fontSize: 12, color: '#71717a' } }
|
||||||
|
: undefined,
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: labels,
|
||||||
|
axisLabel: AXIS_LABEL,
|
||||||
|
axisLine: AXIS_LINE,
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
axisLabel: {
|
||||||
|
...AXIS_LABEL,
|
||||||
|
formatter: yAxisFormatter,
|
||||||
|
},
|
||||||
|
axisLine: AXIS_LINE,
|
||||||
|
splitLine: SPLIT_LINE,
|
||||||
|
},
|
||||||
|
series: series.map((item, index) => ({
|
||||||
|
name: item.name,
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
data: item.data,
|
||||||
|
color: CHART_COLORS[index % CHART_COLORS.length],
|
||||||
|
lineStyle: { width: 2 },
|
||||||
|
showSymbol: false,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/** §7.7: YYYY-MM-DD HH:mm:ss */
|
||||||
|
export function formatDateTime(value) {
|
||||||
|
if (!value) return '-'
|
||||||
|
let iso
|
||||||
|
if (value instanceof Date) {
|
||||||
|
if (Number.isNaN(value.getTime())) return '-'
|
||||||
|
iso = value.toISOString()
|
||||||
|
} else {
|
||||||
|
const d = new Date(value)
|
||||||
|
if (Number.isNaN(d.getTime())) return String(value)
|
||||||
|
iso = d.toISOString()
|
||||||
|
}
|
||||||
|
return iso.replace('T', ' ').slice(0, 19)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 图表 X 轴短标签 */
|
||||||
|
export function formatChartTime(date = new Date()) {
|
||||||
|
const h = String(date.getHours()).padStart(2, '0')
|
||||||
|
const m = String(date.getMinutes()).padStart(2, '0')
|
||||||
|
const s = String(date.getSeconds()).padStart(2, '0')
|
||||||
|
return `${h}:${m}:${s}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export function formatBytes(n) {
|
||||||
|
if (!n) return '0 B'
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
let i = 0
|
||||||
|
let val = n
|
||||||
|
while (val >= 1024 && i < units.length - 1) {
|
||||||
|
val /= 1024
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return `${val.toFixed(1)} ${units[i]}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
export function downloadJSON(filename, data) {
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseImportFile(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(reader.result)
|
||||||
|
const items = Array.isArray(data) ? data : data?.items
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
reject(new Error('文件中没有可导入的配置项'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve(items)
|
||||||
|
} catch {
|
||||||
|
reject(new Error('JSON 格式无效'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.onerror = () => reject(new Error('读取文件失败'))
|
||||||
|
reader.readAsText(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportResource(path, filename) {
|
||||||
|
const { data } = await api.get(`${path}/export`)
|
||||||
|
downloadJSON(filename, data)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importResource(path, mode, items) {
|
||||||
|
const { data } = await api.post(`${path}/import`, { mode, items })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importModeLabel(mode) {
|
||||||
|
const map = {
|
||||||
|
upsert: '覆盖更新(已存在则更新)',
|
||||||
|
skip: '跳过已存在(仅导入新配置)',
|
||||||
|
create: '全部新建(冲突则跳过)',
|
||||||
|
}
|
||||||
|
return map[mode] || mode
|
||||||
|
}
|
||||||
@@ -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`)
|
||||||
|
}
|
||||||