commit 0d84b07f6862505f4d44dff2d7d4b85899d31b05 Author: rose_cat707 Date: Mon Jun 15 04:46:59 2026 +0000 feat: Wormhole 内网穿透与反向代理网关 Go + Vue 管理台:Agent 隧道、域名反代、IP 安全、访客验证与监控大盘。 Gitea Actions CI 多架构镜像;纯 Go SQLite、无 CGO Docker 构建。 Co-authored-by: Cursor diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..be917b2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git/ +bin/ +release/ +data/ +**/.DS_Store +web/node_modules/ +web/dist/ +.gitea/ +*.md +!README.md diff --git a/.gitea/README.md b/.gitea/README.md new file mode 100644 index 0000000..5ac4542 --- /dev/null +++ b/.gitea/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` | diff --git a/.gitea/act-runner/.env.example b/.gitea/act-runner/.env.example new file mode 100644 index 0000000..06b3c00 --- /dev/null +++ b/.gitea/act-runner/.env.example @@ -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 diff --git a/.gitea/act-runner/README.md b/.gitea/act-runner/README.md new file mode 100644 index 0000000..eb068a4 --- /dev/null +++ b/.gitea/act-runner/README.md @@ -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 ` 或重启 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 挂载,具体路径取决于你的安装方式。 diff --git a/.gitea/act-runner/config.yaml b/.gitea/act-runner/config.yaml new file mode 100644 index 0000000..797596b --- /dev/null +++ b/.gitea/act-runner/config.yaml @@ -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 diff --git a/.gitea/act-runner/docker-compose.yml b/.gitea/act-runner/docker-compose.yml new file mode 100644 index 0000000..cd192f1 --- /dev/null +++ b/.gitea/act-runner/docker-compose.yml @@ -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: diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..d95b279 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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:-}" + 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd18f66 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5cbcddf --- /dev/null +++ b/Dockerfile @@ -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 +# +# 生产镜像由 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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3bcbb06 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3c3bcd7 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..add07d1 --- /dev/null +++ b/README.md @@ -0,0 +1,254 @@ +# Wormhole + +内网穿透 + 反向代理网关,带 Web 管理台。控制面自研(Go + SQLite),数据面支持 HTTP/HTTPS 域名反代、TCP/UDP 隧道与 Agent 长连接。 + + +服务端与 Agent 为**同一应用**,配置均在 Web 管理台完成,无需手写配置文件。管理台支持**简体中文、英文**两种界面语言,可在顶栏随时切换。 + +## 界面预览 + +以下为 Web 管理台主要页面(点击可查看原图)。 + +| 登录 | 监控大盘 | +|:---:|:---:| +| ![登录页](docs/image/登陆页.png) | ![监控大盘](docs/image/概览-仪表盘.png) | +| 默认密码 `admin`,首次登录后请修改 | 流量统计、请求 IP 与资源概览 | + +| 客户端 | 隧道 | +|:---:|:---:| +| ![客户端](docs/image/穿透-客户端.png) | ![隧道](docs/image/穿透-隧道.png) | +| Agent 注册、在线状态与 verify_key | TCP/UDP 端口映射、启停与导入导出 | + +| 域名反代 | 访客验证 | +|:---:|:---:| +| ![域名反代](docs/image/穿透-域名.png) | ![访客验证](docs/image/访客验证.png) | +| HTTP/HTTPS Host 路由、TLS 与请求头 | Basic / 表单 / 回调,资源级访问控制 | + +| IP 规则 | 请求 IP | +|:---:|:---:| +| ![IP 规则](docs/image/安全-IP规则.png) | ![请求 IP](docs/image/安全-请求IP.png) | +| 精确 / CIDR / 正则白黑名单 | 访客 IP 监控、一键拉黑 | + +| 访客用户 | 系统设置 | +|:---:|:---:| +| ![访客用户](docs/image/系统-访客用户.png) | ![系统设置](docs/image/系统-系统设置.png) | +| 受保护资源的访客账号管理 | 端口、认证、JWT、导入导出 | + +## 功能概览 + +| 模块 | 说明 | +|------|------| +| 客户端 | Agent 注册、在线状态、verify_key 管理 | +| 隧道 | TCP/UDP 端口映射,支持访客登录与导入导出 | +| 域名反代 | HTTP/HTTPS Host 路由、TLS 证书、代理请求头 | +| 访客验证 | Basic / 表单 / 回调认证,按资源授权访客 | +| IP 安全 | 精确 / CIDR / 正则白黑名单,请求 IP 监控与一键拉黑 | +| 监控大盘 | 流量统计、Top 请求 IP、资源与隧道概览 | +| 系统设置 | 监听端口、JWT、登录限流、数据导入导出 | +| OpenAPI | 仪表盘可打开 `/api/openapi.json` | + +## 架构 + +``` +公网用户 → HTTP/HTTPS 反代 (8081/8443) ──→ 后端服务 / Agent 隧道 + ↑ + Agent Bridge (8528) ←── 内网 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 +``` + +## 部署 + +镜像由 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 +``` + +> **注意**: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 :8528 -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 ` + +### 7. 日常运维 + +| 操作 | 位置 | +|------|------| +| 查看流量与请求 | 监控大盘 | +| 管理 Agent | 客户端 | +| 启停隧道 / 域名 | 隧道、域名 | +| 导出配置备份 | 系统设置 | +| 修改端口 | 系统设置(需重启) | + +## 命令行 + +| 命令 | 说明 | +|------|------| +| `wormhole server` | 启动服务端(默认 `-data ./data`) | +| `wormhole agent -server <地址> -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)。 diff --git a/cmd/wormhole/main.go b/cmd/wormhole/main.go new file mode 100644 index 0000000..9f0761e --- /dev/null +++ b/cmd/wormhole/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "fmt" + "os" + + "github.com/wormhole/wormhole/internal/agent" + "github.com/wormhole/wormhole/internal/server" +) + +func main() { + if len(os.Args) < 2 { + printUsage() + os.Exit(1) + } + + cmd := os.Args[1] + args := os.Args[2:] + + switch cmd { + case "server", "serve", "s": + server.Run(args) + case "agent", "client", "a": + agent.Run(args) + case "help", "-h", "--help": + printUsage() + default: + fmt.Fprintf(os.Stderr, "unknown command: %s\n\n", cmd) + printUsage() + os.Exit(1) + } +} + +func printUsage() { + fmt.Fprintf(os.Stderr, `Wormhole — 内网穿透 / 反向代理(统一二进制) + +用法: + wormhole server 启动服务端(配置在 Web 管理台) + wormhole agent -server <地址> -key 启动客户端 Agent + +简写: + wormhole serve / wormhole s + wormhole client / wormhole a + +示例: + wormhole server + wormhole agent -server 127.0.0.1:8528 -key abc123 + +`) +} diff --git a/docs/UI-DESIGN-SYSTEM.md b/docs/UI-DESIGN-SYSTEM.md new file mode 100644 index 0000000..c6adb10 --- /dev/null +++ b/docs/UI-DESIGN-SYSTEM.md @@ -0,0 +1,2013 @@ +# UI 设计规范 + +> 适用范围:基于 Vue 3 + Element Plus 2.x 的后台管理系统 Web 页面 +> **本文档为唯一设计规范**:色彩、间距、布局、组件样式、全局 CSS 均定义于本文件(§6 分模块说明 + §8.9 完整 CSS)。项目实施时从本文档复制代码即可,不依赖其他设计资产文件。 + +--- + +## 目录 + +1. [页面整体风格](#1-页面整体风格) +2. [布局标准](#2-布局标准) +3. [字体标准](#3-字体标准) +4. [颜色规范](#4-颜色规范) +5. [图标使用规范](#5-图标使用规范) +6. [组件与页面标准样式](#6-组件与页面标准样式) +7. [交互原则](#7-交互原则) +8. [附录:复刻指南](#8-附录复刻指南) + +--- + +## 1. 页面整体风格 + +### 1.1 设计定位 + +本规范定义一套**浅色、克制、数据导向**的后台界面风格。视觉语言强调可读性与操作效率,避免装饰性元素喧宾夺主。 + +| 维度 | 规范 | +|------|------| +| 整体调性 | 专业、冷静、工具感;类似现代 SaaS 控制台 | +| 色彩策略 | 大面积中性灰白底 + 青绿品牌色点缀 | +| 层次表达 | 靠**字号阶梯**与**灰度阶梯**区分,不靠重阴影 | +| 容器形态 | 白底细边框卡片(`1px` + `8px` 圆角),无浮起阴影 | +| 信息密度 | 列表/表格偏紧凑(`size="small"`),表单区留白充分 | +| 技术底座 | Element Plus 组件 + 全局 CSS 变量覆盖 | + +### 1.2 核心设计原则 + +| 原则 | 说明 | +|------|------| +| **浅色优先** | 页面背景 `#f4f4f5`,内容区白卡片;禁止深色全屏侧栏 | +| **克制用色** | 主色青绿仅用于品牌、主按钮、导航激活态、关键正向状态 | +| **细边框卡片** | 白底 + `1px` 浅灰边框 + `8px` 圆角,禁止大面积 box-shadow | +| **8px 间距节奏** | 所有间距使用间距 Token(4px 仅用于极紧凑场景) | +| **层次靠字号与灰度** | 标题 `#18181b`,正文 `#3f3f46`,辅助 `#71717a` | +| **组件复用优先** | 使用 `PageHeader`、`AppCard`、`StatCard` 等标准壳层,禁止页面内自建容器体系 | +| **国际化友好** | 文案走 i18n;布局预留中英文长度差异(按钮组 `flex-wrap`) | + +### 1.3 视觉示意 + +``` +┌──────────────┬────────────────────────────────────┐ +│ Sidebar │ Topbar(52px,白底,底部分割线) │ +│ 240px ├────────────────────────────────────┤ +│ 半透明浅灰 │ Main Content(padding 24px) │ +│ 品牌区+导航 │ .page(max-width 1400px) │ +│ │ PageHeader → StatGrid → AppCard │ +└──────────────┴────────────────────────────────────┘ +``` + +登录页为独立全屏居中布局,无侧栏与顶栏。 + +--- + +## 2. 布局标准 + +### 2.1 栅格系统 + +#### PC 端(≥1025px) + +| 项目 | 规范 | +|------|------| +| 基准栅格 | **12 列**逻辑栅格(内容区 `max-width: 1400px` 内划分) | +| 列间距(gutter) | `16px`(`--space-4`) | +| 页面水平边距 | 主内容区内边距 `24px`(`--space-6`) | +| 典型列分配 | 统计卡 4×3 列;双栏内容 `1.4fr : 1fr` 或 `1fr : 1fr`;三栏等分 `1fr × 3` | + +常用网格类: + +| 类名 | PC 列数 | 说明 | +|------|---------|------| +| `.stat-grid` | 4 列 | 概览指标卡 | +| `.content-grid--2` | 2 列等宽 | 并列卡片 | +| `.content-grid--2-1` | 2 列(约 7:5) | 主内容 + 侧信息 | +| `.content-grid--3` | 3 列等宽 | 三列面板(按需扩展) | + +#### 平板(641px – 1024px) + +| 项目 | 规范 | +|------|------| +| 有效栅格 | **8 列**逻辑栅格 | +| `.stat-grid` | 2 列 | +| `.content-grid--*` | 全部折叠为 **1 列** | +| 主内容 padding | 保持 `24px` | + +#### 移动端(≤640px) + +| 项目 | 规范 | +|------|------| +| 有效栅格 | **4 列**逻辑栅格 | +| `.stat-grid` | 1 列 | +| `.page-header` | 纵向堆叠(标题在上,操作在下) | +| 主内容 padding | `16px`(`--space-4`) | +| 底部 Tab(若启用) | 固定底部,高度 `56px`,图标 `20px` + 文字 `10px` | + +> 默认以 PC 为主、移动端通过响应式折叠适配;若启用底部 Tab 导航,须使用本节尺寸,不得自定义高度。 + +### 2.2 间距体系(8px 基准) + +**硬性规则:页面级、组件级外边距与内边距必须使用间距 Token,禁止出现 5px、7px、10px、15px 等非体系数值。** + +| Token | 值 | 是否 8 的倍数 | 典型用途 | +|-------|-----|---------------|----------| +| `--space-1` | 4px | ½×8,仅紧凑场景 | 标题与副标题间距、行内 code 水平 padding | +| `--space-2` | 8px | ✓ | 按钮组 gap、导航项内边距、表格单元格垂直 padding | +| `--space-3` | 12px | — | 列表项间距、卡片底栏提示 | +| `--space-4` | 16px | ✓ | 卡片间距、工具栏下边距、移动端页面 padding | +| `--space-5` | 20px | — | 卡片水平内边距、统计卡内边距 | +| `--space-6` | 24px | ✓ | 页头下边距、主内容 padding、区块分隔 | +| `--space-8` | 32px | ✓ | 登录卡片内边距、大区块分隔 | + +**垂直节奏(页面级)** + +- `.page-header` 下方:`24px` +- `.stat-grid`、`.content-grid` 下方:`24px` +- 相邻 `.app-card` 间距:`16px`;页面最后一个卡片无底边距 + +**已知例外(保留,勿改)** + +| 位置 | 值 | 原因 | +|------|-----|------| +| `.form-hint` 的 `margin-top` | `6px` | 紧贴表单项控件底部,视觉上等同 8px 体系 | + +`--primary-hover` 已定义但未写独立 CSS 规则,主按钮 hover 由 Element Plus 通过 `--el-color-primary-dark-2` 自动处理。 + +### 2.3 布局组件尺寸 + +| 组件 | 尺寸 | 其他规则 | +|------|------|----------| +| **侧栏** `.sidebar` | 宽 `240px`(`15rem`) | 右边框 `1px`;背景半透明 + `backdrop-filter: blur(8px)` | +| **侧栏品牌区** `.sidebar-brand` | 高随内容;padding `20px 16px` | 品牌图标 `32×32px` | +| **导航项** `.nav-item` | 高随内容;padding `8px 12px`;margin 左右 `8px` | 圆角 `8px`;图标 `16px` | +| **导航分组标签** `.nav-group-label` | padding `16px 16px 8px` | 字号 `12px`,全大写 | +| **顶栏** `.topbar` | 高 **`52px`** | 左右 padding `24px`;白底 + 底边框 | +| **主内容** `.main-content` | flex 填满剩余高度 | padding `24px`(移动端 `16px`);纵向滚动 | +| **页面容器** `.page` | max-width **`1400px`** | 无水平居中强制(随主内容左对齐) | +| **底部 Tab**(预留) | 高 **`56px`** | 图标 `20px`,标签 `10px`,最多 5 项 | +| **登录卡片** `.login-card` | max-width 外层 `400px`;内边距 `32px` | 品牌图标 `48×48px`,圆角 `12px` | + +### 2.4 断点 + +| 断点 | 媒体查询 | 主要变化 | +|------|----------|----------| +| 平板 | `max-width: 1024px` | 统计 2 列;内容网格单列 | +| 手机 | `max-width: 640px` | 统计 1 列;页头纵向;主内容 16px padding | + +### 2.5 布局结构(ASCII) + +``` +PC (≥1025px) +┌─ sidebar 240px ─┬──────────── main-area ────────────────────┐ +│ [brand 32px] │ topbar 52px │ +│ [nav × N] ├───────────────────────────────────────────┤ +│ │ padding 24px │ +│ │ ┌─ page max 1400px ─────────────────┐ │ +│ │ │ page-header │ │ +│ │ │ stat-grid (4 col, gap 16) │ │ +│ │ │ app-card │ │ +│ │ └────────────────────────────────────┘ │ +└─────────────────┴───────────────────────────────────────────┘ + +Mobile (≤640px) +┌──────────────── main-area 100% ────────────────┐ +│ topbar 52px │ +├────────────────────────────────────────────────┤ +│ padding 16px │ +│ page-header (column) │ +│ stat-grid (1 col) │ +│ app-card │ +├────────────────────────────────────────────────┤ +│ [optional bottom-tab 56px] │ +└────────────────────────────────────────────────┘ +``` + +--- + +## 3. 字体标准 + +### 3.1 字体族 + +| 场景 | 字体栈 | +|------|--------| +| **中文界面** | `"PingFang SC"`, `"Microsoft YaHei"`, `"Noto Sans SC"`, `"Helvetica Neue"`, `Arial`, `sans-serif` | +| **英文界面** | `ui-sans-serif`, `system-ui`, `-apple-system`, `"Segoe UI"`, `Roboto`, `"Helvetica Neue"`, `Arial`, `sans-serif` | +| **合并栈(推荐默认)** | `ui-sans-serif`, `system-ui`, `-apple-system`, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif` | +| **等宽(代码/文件名)** | `ui-monospace`, `SFMono-Regular`, `Menlo`, `Monaco`, `Consolas`, monospace` | + +> 中英文共用同一 sans 栈:西文优先 system 字体,CJK 由系统自动 fallback 至苹方/微软雅黑。禁止为中文单独引入 Web Font,保持加载性能。 + +### 3.2 字号阶梯 + +根元素 `font-size: 14px`(`1rem = 14px`)。所有字号使用 `rem` 相对根字号。 + +| 层级 | 类名 / 场景 | 字号 | 字重 | 行高 | 颜色 | +|------|-------------|------|------|------|------| +| 页面标题 | `.page-title` | `1.5rem`(21px) | 600 | 默认 | `--foreground` | +| 登录品牌标题 | `.page-title`(登录页) | 同上 | 600 | 默认 | `--foreground` | +| 卡片标题 | `.app-card__title` | `0.9375rem`(13px) | 600 | 默认 | `--foreground` | +| 品牌名 | `.brand-name` | `1.125rem`(15.75px) | 600 | 默认 | `--foreground` | +| 统计数值 | `.stat-card__value` | `1.75rem`(24.5px) | 600 | 默认 | `--foreground` | +| 正文 / 导航 / 顶栏 | `.nav-item`, `.topbar-title`, 表格正文 | `0.875rem`(12.25px) | 400–500 | 1.4(表格) | `--el-text-color-regular` 或 `--muted-foreground` | +| 描述 / 标签说明 | `.page-desc`, `.stat-card__label` | `0.875rem` | 400 | 默认 | `--muted-foreground` | +| 表头 / 辅助 | 表头 `th`, `.form-hint`, `.nav-group-label` | `0.75rem`(10.5px) | 600(表头)/ 400 | 默认 | `--muted-foreground` | +| 极小说明 | `.form-hint`, `code.inline-code` | `0.75rem` | 400 | 默认 | `--muted-foreground` | +| 等宽数据 | `.kv-block code`, `.meta-list` | `0.8125rem` | 400 | 默认 | 继承 | + +### 3.3 字重与样式 + +| 样式 | 使用场景 | 禁止场景 | +|------|----------|----------| +| **600(Semibold)** | 页面标题、卡片标题、表头、导航激活态、分组标题 `.section-title` | 大段正文 | +| **500(Medium)** | 导航默认态、顶栏次要文字 | — | +| **400(Regular)** | 正文、描述、表单标签 | — | +| **加粗 ``** | 行内强调少量关键词 | 整段文字 | +| **斜体 ``** | 极少使用;仅引用或术语首次出现 | 按钮、导航、表头 | +| **`letter-spacing: -0.02em`** | 仅 `.page-title` | 其他文字 | +| **`text-transform: uppercase`** | 仅 `.nav-group-label` | 其他文字 | + +### 3.4 Element Plus 表单标签 + +| 场景 | `label-width` | +|------|---------------| +| 简单 CRUD 对话框 | `100px` | +| 字段名较长 | `110px` | +| 系统设置长表单 | `120px` | +| 嵌套子表单 | `90px` | +| 登录页 | `size="large"`(EP 默认大号控件) | + +--- + +## 4. 颜色规范 + +### 4.1 色彩角色一览 + +``` +品牌层:primary 系列(操作引导) +中性层:background / card / muted / border(结构) +语义层:success / warning / danger / info(状态) +文本层:foreground / regular / muted-foreground(可读性) +``` + +### 4.2 完整色板 + +#### 品牌色 + +| Token | 色值 | 用途 | +|-------|------|------| +| `--primary` | `#0f7a62` | 主按钮、品牌图标底、导航激活文字、链接强调 | +| `--primary-hover` | `#0d6b56` | 主按钮 hover | +| `--primary-foreground` | `#fafafa` | 主色背景上的文字/图标 | +| `--el-color-primary-light-3` | `#4da08c` | EP 组件浅色变体 | +| `--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` | 主色按下/深色态 | + +#### 中性色 + +| Token | 色值 | 用途 | +|-------|------|------| +| `--background` | `#f4f4f5` | 页面底色、登录页背景 | +| `--foreground` | `#18181b` | 主标题、强强调正文 | +| `--card` | `#ffffff` | 卡片、顶栏、弹窗内容区背景 | +| `--card-foreground` | `#18181b` | 卡片上主文字 | +| `--muted` | `#f4f4f5` | 表头背景、内嵌块、行内 code 底 | +| `--muted-foreground` | `#71717a` | 描述、表头文字、顶栏标题、占位性文字 | +| `--border` | `#e4e4e7` | 卡片边框、分割线、顶栏底边 | +| `--input` | `#e4e4e7` | 输入框边框(与 border 一致) | + +#### 侧栏专用 + +| Token | 色值 | 用途 | +|-------|------|------| +| `--sidebar` | `rgba(250, 250, 250, 0.85)` | 侧栏背景(半透明) | +| `--sidebar-foreground` | `#18181b` | 侧栏主文字 | +| `--sidebar-accent` | `#f4f4f5` | 导航 hover / active 背景 | +| `--sidebar-border` | `#e4e4e7` | 侧栏右边框 | + +#### 语义色 + +| Token / 类名 | 色值 | 用途 | +|--------------|------|------| +| `--success` | `#047857` | 成功状态(与 EP success 同步) | +| `--warning` | `#b45309` | 警告状态 | +| `--danger` / `--destructive` | `#dc2626` | 危险、错误、删除 | +| `--info` | `#a1a1aa` | 中性、禁用、分类标签 | +| `--el-color-success` | `#047857` | `el-tag type="success"` | +| `--el-color-warning` | `#b45309` | `el-tag type="warning"` | +| `--el-color-danger` | `#dc2626` | `el-tag type="danger"` | +| `--el-color-info` | `#a1a1aa` | `el-tag type="info"` | +| `.status-good` | `#047857` | 数值/状态优良 | +| `.status-warn` | `#b45309` | 数值/状态偏高 | +| `.status-bad` | `#dc2626` | 数值/状态很差 | +| `.status-muted` | `--muted-foreground` | 无数据/超时/不可用 | +| `.meta-list__ok` | `var(--success)` | 列表项就绪 | +| `.meta-list__warn` | `var(--warning)` | 列表项缺失/警告 | + +#### 文本色(Element Plus 对齐) + +| Token | 色值 | 用途 | +|-------|------|------| +| `--el-text-color-primary` | `#18181b` | EP 主要文字 | +| `--el-text-color-regular` | `#3f3f46` | 表格正文 | +| `--el-text-color-secondary` | `#71717a` | EP 次要文字 | + +### 4.3 颜色使用规范 + +| 规则 | 说明 | +|------|------| +| **禁止硬编码色值** | 新样式必须使用 CSS 变量 | +| **主色克制** | 单屏主色面积不超过 15%;除主按钮与激活导航外不大面积铺主色 | +| **状态色统一** | 成功/失败/禁用一律用 `el-tag` 语义 type,不自创色块 | +| **边框优先于阴影** | 容器分隔用 `1px solid var(--border)`,不用阴影 | +| **危险操作** | 破坏性操作用 `type="danger"` + `link`(表格行内)或二次确认弹框 | +| **图表** | 遵循 [§6.16](#616-图表规范echarts) 配色与 grid 配置 | + +### 4.4 圆角 + +| Token | 值 | 用途 | +|-------|-----|------| +| `--radius` | `0.5rem`(8px) | 卡片、按钮、输入框、导航项、内嵌块 | +| 登录品牌图标 | `12px` | 仅 `.login-brand-icon`,略大以强化品牌 | + +--- + +## 5. 图标使用规范 + +### 5.1 图标体系 + +| 类型 | 来源 | 说明 | +|------|------|------| +| **品牌图标** | 项目自有 Logo(自行实现) | 产品标识;仅用于侧栏 `.brand-icon`、登录页 `.login-brand-icon`;**各项目图形不同,本文档不提供参考实现** | +| **功能图标** | `@element-plus/icons-vue` | 导航、操作提示;通过 `` 包裹 | +| **状态** | 优先 `el-tag` 文字 | 不以图标替代明确状态文案 | + +### 5.2 尺寸规范 + +| 场景 | 尺寸 | +|------|------| +| 侧栏品牌区 Logo | `20px` | +| 登录页品牌 Logo | `24px` | +| 侧栏导航图标 | `16px`(``) | +| 按钮内图标(若使用) | `14px` | +| 底部 Tab(预留) | `20px` | + +### 5.3 常见场景图标推荐 + +图标库统一使用 `@element-plus/icons-vue`(Outlined 线型)。下表为常见导航/功能场景的推荐映射,各项目可按业务替换,但须保持风格一致。 + +| 场景 | 推荐图标 | 含义 | +|------|----------|------| +| 首页 / 仪表盘 | `Odometer`、`HomeFilled` | 总览、监控 | +| 列表 / 规则 / 配置项 | `List`、`Menu` | 条目化管理 | +| 连接 / 网络 | `Connection`、`Share` | 链路、拓扑 | +| 外部关联 / 链接 | `Link` | 外部数据源 | +| 切换 / 路由 / 策略 | `Switch` | 分流、调度 | +| 日志 / 文档 | `Document`、`Tickets` | 记录、流水 | +| 用户 / 权限 | `User`、`Lock` | 身份、安全 | +| 系统设置 | `Setting` | 全局配置 | +| 新建 | `Plus` | 创建资源 | +| 编辑 | `Edit` | 修改 | +| 删除 | `Delete` | 移除(配合 danger 按钮,不单用图标) | +| 刷新 | `Refresh` | 重新加载 | +| 导入 / 导出 | `Upload`、`Download` | 数据交换 | + +### 5.4 使用原则 + +| 原则 | 说明 | +|------|------| +| **导航必带图标** | 侧栏每项左侧固定 `16px` 图标,增强扫读性 | +| **颜色跟随文字** | 图标 `currentColor`,不单独设色 | +| **禁止混用风格** | 统一 Outlined 线型图标(Element Plus Icons) | +| **操作按钮** | 主操作优先文字按钮;图标-only 按钮需 `aria-label` | +| **品牌图标不外借** | Logo 仅用于品牌区,不用于普通操作按钮 | + +--- + +## 6. 组件与页面标准样式 + +> 本节 6.1–6.14 为**分模块说明**(便于查阅);**合并后的完整全局 CSS 见 [§8.9](#89-完整全局样式一键复制)**。两处内容须保持一致。 + +### 6.0 技术栈与引入 + +| 项目 | 要求 | +|------|------| +| Vue | `^3.4` | +| Element Plus | `^2.4`(2.x 均可,须使用 CSS 变量主题) | +| 图标 | `@element-plus/icons-vue` | +| 图表(可选) | `echarts` `^5` | + +**`main.ts` 引入顺序(顺序不可颠倒):** + +```ts +import { createApp } from 'vue' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' // ① 先 Element Plus 基础样式 +import './styles/global.css' // ② 再将本文档 §8.9 复制为全局样式文件后引入 + +const app = createApp(App) +app.use(ElementPlus) // ③ 全量注册(或按需,但变量须生效) +app.mount('#app') +``` + +**依赖说明:** + +- 全局样式**仅来源于本文档 §8.9**,文件名与存放路径由项目自定(如 `global.css`、`app.css`)。 +- 按钮、输入框、弹框、Tabs 等**无需逐组件写 CSS**,通过 `:root` 中的 `--el-*` 变量与品牌色对齐。 +- 表格、卡片容器、布局壳层使用本文档自定义 class,须与 §8.1 壳层 DOM 结构配合。 + +### 6.1 设计 Token 与基础重置 + +```css +: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); +} +``` + +### 6.2 布局壳层(侧栏 / 顶栏 / 主内容) + +```css +.layout { + display: flex; + height: 100vh; +} + +.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; +} + +.topbar { + height: 52px; + 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); +} + +.main-content { + flex: 1; + overflow-y: auto; + padding: var(--space-6); +} +``` + +### 6.3 页面头与网格 + +```css +.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; +} + +.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); } +``` + +### 6.4 卡片与工具栏 + +```css +.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); } + +.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); +} +``` + +### 6.5 表单、间距工具类 + +```css +.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); } + +.el-form .el-select { width: 100%; } +``` + +### 6.6 登录页 + +```css +.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; +} +``` + +### 6.7 表格 + +```css +.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-pager .el-pagination { + font-size: 0.875rem; +} +``` + +**表格列宽约定** + +| 列类型 | 宽度 | +|--------|------| +| 时间 | `168px` | +| 状态 / 布尔 | `80–100px` | +| 操作 | `120–160px`,`fixed="right"` | +| 主文本 | `min-width`,不设固定宽 | + +### 6.8 工具栏控件宽度 + +```css +.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; } + +.card-toolbar .toolbar-input--grow { + flex: 1; + min-width: 200px; +} +``` + +### 6.9 按钮规范(Element Plus 约定) + +| 场景 | 写法 | +|------|------| +| 页面主操作 | `` | +| 页头次要操作 | 默认按钮(无 type) | +| 表格行内操作 | `` | +| 表格行内删除 | `` | +| 顶栏退出 | `` | +| 登录提交 | `` | +| 加载中 | `:loading="true"`,禁止重复点击 | + +### 6.10 弹框与抽屉 + +| 类型 | 宽度 | 属性 | +|------|------|------| +| 简单 CRUD | `480–520px` | `destroy-on-close` | +| 导入/中等表单 | `560–640px` | 同上 | +| 复杂列表弹窗 | `720px` | 同上 | +| 二级编辑 | 抽屉 `50%` | — | + +**底部按钮顺序**:取消(默认)在左,确认(`type="primary"`)在右。 + +### 6.11 标签(Tag) + +统一 `size="small"`: + +| 语义 | type | +|------|------| +| 启用 / 成功 / 存活 | `success` | +| 禁用 / 中性 | `info` | +| 失败 / 错误 | `danger` | +| 警告 / 未就绪 | `warning` | +| 可选项高亮 | `primary` | + +### 6.12 状态色与辅助块 + +```css +.status-good { color: #047857; } +.status-warn { color: #b45309; } +.status-bad { color: #dc2626; } +.status-muted { color: var(--muted-foreground); } + +.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; +} +``` + +### 6.13 滚动条与响应式 + +```css +.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; +} + +@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; } +} +``` + +### 6.14 全局工具类速查 + +| 类名 | 用途 | +|------|------| +| `.card-toolbar` | 卡片内筛选行 | +| `.card-pager` | 卡片内分页(右对齐) | +| `.card-header-row` | 卡片头标题 + 右侧操作 | +| `.form-hint` | 表单项下方说明 | +| `.section-title` | 长表单分组标题 | +| `.page-toolbar` | 页内独立工具条 | +| `.tabs-bar` | 卡片内 Tab 容器 | +| `.toolbar-select` / `.toolbar-input` | 工具栏控件宽度 | +| `.table-scroll--sm/md/lg` | 表格最大高度 | +| `.w-full` | 宽度 100% | +| `.u-mb-4` / `.u-mt-3` 等 | 标准外边距 | +| `.status-good` / `.status-warn` / `.status-bad` | 行内数值/状态语义色 | +| `.kv-block` | 键值对结果展示(常配合 `.card-inset-block`) | +| `.cell-stack` | 表格单元格内纵向堆叠 | +| `.meta-list` | 元数据清单列表 | +| `.custom-scrollbar` | 细滚动条(Firefox + WebKit) | + +### 6.15 Element Plus 适配说明 + +以下组件**依靠 EP 默认样式 + `:root` 变量**即可,禁止重复覆盖(除非本节另有说明): + +| 组件 | 使用约定 | 自定义 CSS | +|------|----------|------------| +| `el-button` | 主操作 `type="primary"`;行内 `size="small" link` | 无(颜色来自 `--el-color-primary*`) | +| `el-input` / `el-select` | 表单内 select 宽度 `100%` | 仅 `.el-form .el-select { width: 100% }` | +| `el-dialog` | `destroy-on-close`;宽度见 §6.10 | 无 | +| `el-drawer` | 二级编辑 `size="50%"` | 无 | +| `el-tabs` | 放在 `.tabs-bar` 内 | 仅 `.tabs-bar .el-tabs__header { margin-bottom: 0 }`;激活色来自 `--el-color-primary` | +| `el-pagination` | 放在 `.card-pager` 内 | 仅 `font-size: 0.875rem` | +| `el-tag` | 统一 `size="small"` | 语义色来自 `--el-color-success` 等 | +| `el-message` / `el-message-box` | 操作反馈 | 无 | +| `el-form` | `label-width` 见 §3.4 | 无 | + +**Tabs 推荐结构:** + +```vue + +
+ + + + +
+ +
+``` + +**Dialog 推荐结构:** + +```vue + + + + +``` + +### 6.16 图表规范(ECharts) + +含折线图的统计页使用 `.chart-box` 容器(高度 `280px`)。图表配置须与品牌色一致: + +| 配置项 | 值 | +|--------|-----| +| 容器 class | `chart-box`(`height: 280px`) | +| 主系列色 | `#0f7a62`(`var(--primary)`) | +| 次系列色 | `#71717a`(`var(--muted-foreground)`) | +| 第三系列及以后 | `#4da08c`、`#76b8a8`、`#a0d0c4`(primary-light 阶梯) | +| `grid` | `{ left: 40, right: 16, top: 16, bottom: 24 }` | +| 坐标轴字号 | `12`(与 `0.857rem` 接近) | +| 坐标轴颜色 | `#71717a` | +| 分割线 | `#e4e4e7` | +| 折线 | `smooth: true`;线宽 `2` | +| tooltip | `trigger: 'axis'` | +| 图例 | 顶部或底部,字号 `12` | + +```ts +chart.setOption({ + grid: { left: 40, right: 16, top: 16, bottom: 24 }, + xAxis: { + type: 'category', + data: labels, + axisLabel: { color: '#71717a', fontSize: 12 }, + axisLine: { lineStyle: { color: '#e4e4e7' } }, + }, + yAxis: { + type: 'value', + axisLabel: { color: '#71717a', fontSize: 12 }, + splitLine: { lineStyle: { color: '#e4e4e7' } }, + }, + series: [ + { name: '主指标', type: 'line', smooth: true, data: a, color: '#0f7a62' }, + { name: '次指标', type: 'line', smooth: true, data: b, color: '#71717a' }, + ], + tooltip: { trigger: 'axis' }, +}) +``` + +--- + +## 7. 交互原则 + +### 7.1 反馈与状态 + +| 原则 | 规范 | +|------|------| +| **操作反馈** | 成功/失败使用消息提示(如 `ElMessage`);破坏性操作须二次确认(如 `ElMessageBox.confirm`) | +| **加载态** | 列表 `v-loading`;按钮 `:loading`;全页加载用卡片级 loading | +| **空状态** | 表格无数据依赖 EP 默认 empty;需文案时走 i18n | +| **禁用态** | 批量任务进行中禁用与之互斥的行内操作 | +| **乐观更新** | 不做乐观更新;等 API 成功后再刷新列表 | + +### 7.2 导航与层级 + +| 原则 | 规范 | +|------|------| +| **路由即导航** | 侧栏链接与当前路由同步,激活态高亮 | +| **顶栏标题** | 显示当前一级导航名称,非重复页面 H1 | +| **页内标题** | `PageHeader` 的 `title` 为页面 H1,描述可选 | +| **退出登录** | 顶栏右侧 `text` 按钮,清除会话后跳转登录页 | + +### 7.3 表单与对话框 + +| 原则 | 规范 | +|------|------| +| **关闭销毁** | 对话框统一 `destroy-on-close`,避免脏数据残留 | +| **提交防重** | 提交按钮 `loading` 期间禁止重复提交 | +| **取消不保存** | 取消仅关闭弹层,不触发 API | +| **密码字段** | `type="password"` + `show-password` | +| **字段说明** | 用 `.form-hint`,不用 placeholder 替代说明 | + +### 7.4 列表与表格 + +| 原则 | 规范 | +|------|------| +| **主操作位置** | 新建/导入放 `PageHeader#actions`;筛选放 `.card-toolbar` | +| **行内操作** | 链接式小按钮,删除放最右 | +| **分页** | 放 `.card-pager`,右对齐 | +| **刷新** | 提供显式「刷新」按钮;除监控大屏等实时场景外,不依赖隐式自动刷新 | +| **固定列** | 操作列 `fixed="right"` | + +### 7.5 动效与过渡 + +| 原则 | 规范 | +|------|------| +| **克制动效** | 仅使用 EP 默认过渡(对话框、下拉、Message) | +| **禁止花哨动画** | 不加自定义 page transition、弹跳、视差 | +| **侧栏** | 桌面端固定展开,不用折叠动画(移动端待扩展) | + +### 7.6 无障碍与可用性 + +| 原则 | 规范 | +|------|------| +| **对比度** | 正文与背景对比度 ≥ 4.5:1 | +| **焦点** | 依赖 EP 默认 focus ring,不自行移除 | +| **点击目标** | 按钮最小高度遵循 EP(默认 32px,large 40px) | +| **错误信息** | 接口错误映射为用户可读的本地化文案(如限流、权限不足) | + +### 7.7 国际化 + +| 原则 | 规范 | +|------|------| +| **文案** | 所有可见文字走国际化方案(如 i18n),禁止硬编码用户可见字符串 | +| **时间** | 显示格式 `YYYY-MM-DD HH:mm:ss`(ISO 替换 `T` 并截断) | +| **布局弹性** | 按钮组、工具栏 `flex-wrap: wrap` 适配长文案 | + +--- + +## 8. 附录:复刻指南 + +按以下顺序实施,可在新项目中复刻本文档定义的全部视觉(Vue 3 + Element Plus 2.x)。 + +### 8.1 壳层布局完整模板 + +**DOM 层级(class 名称不可改):** + +``` +.layout +├── .sidebar.custom-scrollbar +│ ├── .sidebar-brand +│ │ ├── .brand-icon → 项目自有 Logo(20px) +│ │ └── .brand-name +│ ├── .nav-group-label(可选分组) +│ └── a.nav-item[.active] × N(含 el-icon 16px) +└── .main-area + ├── header.topbar + │ ├── .topbar-title + │ └── 顶栏操作(如 el-button text) + └── main.main-content.custom-scrollbar + └── router-view → 各页面 .page +``` + +```vue + + + + +``` + +### 8.2 登录页完整模板 + +```vue + + + + +``` + +### 8.3 公共组件完整源码 + +```vue + + + + +``` + +```vue + + + + +``` + +```vue + + + + +``` + +品牌 Logo 由各项目自行提供,放入 `.brand-icon`(20px)与 `.login-brand-icon`(24px)容器即可;须使用 `currentColor` 或适配主色底上的 `--primary-foreground`。 + +### 8.4 标准页面模板 + +```vue + +``` + +### 8.5 带 Tab 的列表页 + +```vue + +
+ + + + +
+
+ +
+
+``` + +### 8.6 新页面 Checklist + +1. 将 §8.9 复制为项目全局样式文件,按 §6.0 顺序在 `main.ts` 中引入 +2. 复制 §8.1 壳层布局、§8.3 公共组件(文件名自定) +3. 页面根节点 `
` +4. `PageHeader`(标题 + 描述 + actions) +5. 主内容 `AppCard`;表单页加 `padded` +6. 列表筛选用 `.card-toolbar`,分页用 `.card-pager` +7. 间距与颜色仅使用 CSS 变量与全局类 +8. 在主布局中注册导航项与图标(§5.3) + +### 8.7 反模式 + +| 不要 | 应该 | +|------|------| +| 深色侧栏 | 浅色半透明侧栏 | +| 页面内裸 `el-card` 作主容器 | `AppCard` | +| 无标题直接上表格 | 先 `PageHeader` | +| 硬编码色值 | CSS 变量 | +| 重阴影 | 细边框 | +| 每页自定义 `.toolbar` / `.pager` | `.card-toolbar` / `.card-pager` | +| 表单卡片不加 `padded` | `AppCard padded` | +| `style="margin-top: 12px"` | `.u-mt-3` 等工具类 | +| 非 8px 倍数的随意间距 | 间距 Token | +| 在 EP 样式之前引入全局样式 | 先 `element-plus/dist/index.css`,再引入 §8.9 全局样式 | + +### 8.8 推荐目录结构(示例) + +``` +styles/global.css ← 由本文档 §8.9 复制生成,路径与文件名自定 +components/PageHeader.vue +components/StatCard.vue +components/AppCard.vue +layouts/MainLayout.vue ← 见 §8.1,文件名自定 +views/Login.vue ← 见 §8.2,文件名自定 +``` + +样式变更时,须同时更新本文档 **§6.1–6.14** 与 **§8.9** 两处 CSS,保持内容一致。 + +### 8.9 完整全局样式(一键复制) + +以下为**设计规范定义的完整全局 CSS**,直接复制到项目全局样式文件即可(§6.1–6.14 为分模块对照版,内容须与本节一致)。 + +```css +: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); +} + +.layout { + display: flex; + height: 100vh; +} + +.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; +} + +.topbar { + height: 52px; + 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); +} + +.main-content { + flex: 1; + overflow-y: auto; + padding: var(--space-6); +} + +.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%; } + +.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; +} + +@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; } +} +``` + +--- + +*浅色后台管理系统 UI 设计规范* diff --git a/docs/image/安全-IP规则.png b/docs/image/安全-IP规则.png new file mode 100644 index 0000000..b2f66a4 Binary files /dev/null and b/docs/image/安全-IP规则.png differ diff --git a/docs/image/安全-请求IP.png b/docs/image/安全-请求IP.png new file mode 100644 index 0000000..6a84014 Binary files /dev/null and b/docs/image/安全-请求IP.png differ diff --git a/docs/image/概览-仪表盘.png b/docs/image/概览-仪表盘.png new file mode 100644 index 0000000..4e29ef6 Binary files /dev/null and b/docs/image/概览-仪表盘.png differ diff --git a/docs/image/登陆页.png b/docs/image/登陆页.png new file mode 100644 index 0000000..a1181ed Binary files /dev/null and b/docs/image/登陆页.png differ diff --git a/docs/image/穿透-域名.png b/docs/image/穿透-域名.png new file mode 100644 index 0000000..93f7a93 Binary files /dev/null and b/docs/image/穿透-域名.png differ diff --git a/docs/image/穿透-客户端.png b/docs/image/穿透-客户端.png new file mode 100644 index 0000000..4dbf519 Binary files /dev/null and b/docs/image/穿透-客户端.png differ diff --git a/docs/image/穿透-隧道.png b/docs/image/穿透-隧道.png new file mode 100644 index 0000000..f45c7fe Binary files /dev/null and b/docs/image/穿透-隧道.png differ diff --git a/docs/image/系统-系统设置.png b/docs/image/系统-系统设置.png new file mode 100644 index 0000000..1ee48c0 Binary files /dev/null and b/docs/image/系统-系统设置.png differ diff --git a/docs/image/系统-访客用户.png b/docs/image/系统-访客用户.png new file mode 100644 index 0000000..32ccecc Binary files /dev/null and b/docs/image/系统-访客用户.png differ diff --git a/docs/image/访客验证.png b/docs/image/访客验证.png new file mode 100644 index 0000000..8f622ec Binary files /dev/null and b/docs/image/访客验证.png differ diff --git a/go.env b/go.env new file mode 100644 index 0000000..f089615 --- /dev/null +++ b/go.env @@ -0,0 +1,3 @@ +# 项目级 Go 工具链环境(go mod / go build 会读取) +GOPROXY=https://goproxy.cn,direct +GOSUMDB=sum.golang.google.cn diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a1af421 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0b5280c --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/agent/agent.go b/internal/agent/agent.go new file mode 100644 index 0000000..291c226 --- /dev/null +++ b/internal/agent/agent.go @@ -0,0 +1,229 @@ +package agent + +import ( + "crypto/tls" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net" + "os" + "os/signal" + "syscall" + "time" + + "github.com/xtaci/smux" + "github.com/wormhole/wormhole/internal/bridge" + "github.com/wormhole/wormhole/internal/protocol" + "github.com/wormhole/wormhole/internal/version" +) + +func Run(args []string) { + fs := flag.NewFlagSet("agent", flag.ExitOnError) + serverAddr := fs.String("server", "127.0.0.1:8528", "wormhole server bridge address") + verifyKey := fs.String("key", "", "client verify key") + useTLS := fs.Bool("tls", false, "use TLS for bridge connection") + fs.Parse(args) + + if *verifyKey == "" { + log.Fatal("verify key required: -key") + } + + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + + backoff := time.Second + for { + select { + case <-sig: + log.Println("[agent] shutting down") + return + default: + } + + shutdown, hadSession := runSession(*serverAddr, *verifyKey, *useTLS, sig) + if shutdown { + return + } + if hadSession { + backoff = time.Second + } + + log.Printf("[agent] reconnecting in %v...", backoff) + select { + case <-sig: + log.Println("[agent] shutting down") + return + case <-time.After(backoff): + } + if backoff < 30*time.Second { + backoff *= 2 + } + } +} + +func dialBridge(addr string, useTLS bool) (net.Conn, error) { + var conn net.Conn + var err error + if useTLS { + conn, err = tls.Dial("tcp", addr, &tls.Config{ + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: true, + }) + } else { + conn, err = net.Dial("tcp", addr) + } + if err != nil { + return nil, err + } + bridge.SetTCPKeepAlive(conn) + return conn, nil +} + +// runSession connects and blocks until disconnect. +// Returns shutdown=true when SIGINT/SIGTERM received; hadSession=true if registration succeeded. +func runSession(serverAddr, verifyKey string, useTLS bool, sig chan os.Signal) (shutdown, hadSession bool) { + conn, err := dialBridge(serverAddr, useTLS) + if err != nil { + log.Printf("[agent] dial server: %v", err) + return false, false + } + defer conn.Close() + + if err := protocol.WriteMessage(conn, protocol.MsgRegister, protocol.RegisterPayload{ + VerifyKey: verifyKey, + Version: version.Version, + }); err != nil { + log.Printf("[agent] register: %v", err) + return false, false + } + msgType, body, err := protocol.ReadMessage(conn) + if err != nil { + log.Printf("[agent] register ack: %v", err) + return false, false + } + if msgType != protocol.MsgRegisterAck { + log.Printf("[agent] unexpected register response") + return false, false + } + var ack protocol.RegisterAckPayload + if len(body) > 0 { + if err := json.Unmarshal(body, &ack); err != nil { + log.Printf("[agent] parse ack: %v", err) + return false, false + } + } + if !ack.OK { + log.Printf("[agent] register failed: %s", ack.Message) + return false, false + } + log.Printf("[agent] registered as client %d", ack.ClientID) + hadSession = true + + session, err := smux.Server(conn, bridge.SmuxConfig()) + if err != nil { + log.Printf("[agent] smux: %v", err) + return false, hadSession + } + defer session.Close() + + sessionDead := make(chan string, 1) + go func() { + reason := heartbeat(session) + sessionDead <- reason + }() + + go func() { + for { + stream, err := session.AcceptStream() + if err != nil { + select { + case sessionDead <- "accept stream: " + err.Error(): + default: + } + return + } + go handleStream(stream) + } + }() + + select { + case <-sig: + return true, hadSession + case reason := <-sessionDead: + log.Printf("[agent] session closed (%s)", reason) + return false, hadSession + } +} + +const heartbeatMaxFailures = 3 + +func heartbeat(session *smux.Session) string { + ticker := time.NewTicker(bridge.HeartbeatInterval) + defer ticker.Stop() + failures := 0 + for range ticker.C { + if err := pingOnce(session); err != nil { + failures++ + log.Printf("[agent] heartbeat failed (%d/%d): %v", failures, heartbeatMaxFailures, err) + if failures >= heartbeatMaxFailures { + return "heartbeat timeout" + } + continue + } + failures = 0 + } + return "heartbeat stopped" +} + +func pingOnce(session *smux.Session) error { + stream, err := session.OpenStream() + if err != nil { + return err + } + defer stream.Close() + if err := protocol.WriteMessage(stream, protocol.MsgPing, nil); err != nil { + return err + } + msgType, _, err := protocol.ReadMessage(stream) + if err != nil { + return err + } + if msgType != protocol.MsgPong { + return fmt.Errorf("unexpected response type %d", msgType) + } + return nil +} + +func handleStream(stream *smux.Stream) { + defer stream.Close() + msgType, body, err := protocol.ReadMessage(stream) + if err != nil { + return + } + switch msgType { + case protocol.MsgPing: + _ = protocol.WriteMessage(stream, protocol.MsgPong, nil) + return + case protocol.MsgOpenStream: + handleDataStream(stream, body) + } +} + +func handleDataStream(stream *smux.Stream, body []byte) { + meta, err := protocol.ParseLinkMeta(body) + if err != nil { + return + } + target, err := net.Dial("tcp", meta.Target) + if err != nil { + return + } + defer target.Close() + _ = protocol.WriteMessage(stream, protocol.MsgStreamReady, nil) + go func() { + _, _ = io.Copy(target, stream) + }() + _, _ = io.Copy(stream, target) +} diff --git a/internal/api/extra_handlers.go b/internal/api/extra_handlers.go new file mode 100644 index 0000000..b597de7 --- /dev/null +++ b/internal/api/extra_handlers.go @@ -0,0 +1,174 @@ +package api + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/wormhole/wormhole/internal/auth" + "github.com/wormhole/wormhole/internal/store" +) + +func (s *Server) dashboardRankings(c *gin.Context) { + rankType := c.DefaultQuery("type", "tunnels") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50")) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 50 + } + if pageSize > 500 { + pageSize = 500 + } + offset := (page - 1) * pageSize + q := c.Query("q") + + switch rankType { + case "tunnels": + items, total, err := s.store.ListTunnelsByFlow(pageSize, offset) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"items": items, "total": total}) + case "hosts": + items, total, err := s.store.ListHostsByFlow(pageSize, offset) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"items": items, "total": total}) + case "ips": + items, total, err := s.store.ListAllRequestIPs(pageSize, offset, q) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"items": 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) +} diff --git a/internal/api/import_export.go b/internal/api/import_export.go new file mode 100644 index 0000000..ec8acd4 --- /dev/null +++ b/internal/api/import_export.go @@ -0,0 +1,348 @@ +package api + +import ( + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/wormhole/wormhole/internal/auth" + "github.com/wormhole/wormhole/internal/store" +) + +type importResult struct { + Created int `json:"created"` + Updated int `json:"updated"` + Skipped int `json:"skipped"` + Failed int `json:"failed"` + Errors []string `json:"errors"` +} + +type tunnelImportRequest struct { + Mode string `json:"mode"` // create, upsert, skip + Items []store.TunnelExportItem `json:"items"` +} + +type hostImportRequest struct { + Mode string `json:"mode"` + Items []store.HostExportItem `json:"items"` +} + +func (s *Server) exportTunnels(c *gin.Context) { + claims := getClaims(c) + clientID, _ := strconv.ParseUint(c.Query("client_id"), 10, 64) + tunnels, err := s.store.ListTunnels(uint(clientID), claims.UserID, auth.IsAdmin(claims.Role)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + clientNames := tunnelClientNames(s.store, tunnels) + items := make([]store.TunnelExportItem, 0, len(tunnels)) + for _, t := range tunnels { + items = append(items, store.TunnelToExportItem(t, clientNames[t.ClientID])) + } + doc := store.TunnelExportDoc{ + Version: store.ExportVersion, + ExportedAt: time.Now().UTC(), + Items: items, + } + c.Header("Content-Disposition", `attachment; filename="wormhole-tunnels.json"`) + c.JSON(http.StatusOK, doc) +} + +func (s *Server) importTunnels(c *gin.Context) { + claims := getClaims(c) + var req tunnelImportRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"}) + return + } + if len(req.Items) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "items is empty"}) + return + } + mode := req.Mode + if mode == "" { + mode = "upsert" + } + result := importResult{} + for i, item := range req.Items { + clientID, err := store.ResolveImportClientID(s.store, item.ClientID, item.ClientName, claims.UserID, auth.IsAdmin(claims.Role)) + if err != nil { + result.Failed++ + result.Errors = append(result.Errors, formatImportError("tunnel", i+1, err)) + continue + } + t := tunnelFromImportItem(item, clientID) + if err := normalizeTunnel(&t); err != nil { + result.Failed++ + result.Errors = append(result.Errors, formatImportError("tunnel", i+1, err)) + continue + } + existing, err := s.store.FindTunnelByPort(t.ListenPort) + switch mode { + case "create": + if err == nil { + result.Skipped++ + continue + } + if err := s.store.CreateTunnel(&t); err != nil { + result.Failed++ + result.Errors = append(result.Errors, formatImportError("tunnel", i+1, err)) + continue + } + s.cache.UpsertTunnel(&t) + if t.Status { + _ = s.proxy.StartTunnel(t.ID) + } + result.Created++ + case "skip": + if err == nil { + result.Skipped++ + continue + } + if err := s.store.CreateTunnel(&t); err != nil { + result.Failed++ + result.Errors = append(result.Errors, formatImportError("tunnel", i+1, err)) + continue + } + s.cache.UpsertTunnel(&t) + if t.Status { + _ = s.proxy.StartTunnel(t.ID) + } + result.Created++ + default: // upsert + if err != nil { + if err := s.store.CreateTunnel(&t); err != nil { + result.Failed++ + result.Errors = append(result.Errors, formatImportError("tunnel", i+1, err)) + continue + } + s.cache.UpsertTunnel(&t) + if t.Status { + _ = s.proxy.StartTunnel(t.ID) + } + result.Created++ + continue + } + wasRunning := existing.RunStatus + if wasRunning { + _ = s.proxy.StopTunnel(existing.ID) + } + t.ID = existing.ID + t.ClientID = existing.ClientID + t.RunStatus = false + if err := s.store.UpdateTunnel(&t); err != nil { + result.Failed++ + result.Errors = append(result.Errors, formatImportError("tunnel", i+1, err)) + continue + } + s.cache.UpsertTunnel(&t) + if t.Status { + _ = s.proxy.StartTunnel(t.ID) + } + result.Updated++ + } + } + c.JSON(http.StatusOK, result) +} + +func (s *Server) exportHosts(c *gin.Context) { + claims := getClaims(c) + clientID, _ := strconv.ParseUint(c.Query("client_id"), 10, 64) + hosts, err := s.store.ListHosts(uint(clientID), claims.UserID, auth.IsAdmin(claims.Role)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + clientNames := hostClientNames(s.store, hosts) + items := make([]store.HostExportItem, 0, len(hosts)) + for _, h := range hosts { + items = append(items, store.HostToExportItem(h, clientNames[h.ClientID])) + } + doc := store.HostExportDoc{ + Version: store.ExportVersion, + ExportedAt: time.Now().UTC(), + Items: items, + } + c.Header("Content-Disposition", `attachment; filename="wormhole-hosts.json"`) + c.JSON(http.StatusOK, doc) +} + +func (s *Server) importHosts(c *gin.Context) { + claims := getClaims(c) + var req hostImportRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"}) + return + } + if len(req.Items) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "items is empty"}) + return + } + mode := req.Mode + if mode == "" { + mode = "upsert" + } + result := importResult{} + for i, item := range req.Items { + clientID, err := store.ResolveImportClientID(s.store, item.ClientID, item.ClientName, claims.UserID, auth.IsAdmin(claims.Role)) + if err != nil { + result.Failed++ + result.Errors = append(result.Errors, formatImportError("host", i+1, err)) + continue + } + h := hostFromImportItem(item, clientID) + if err := normalizeHost(&h); err != nil { + result.Failed++ + result.Errors = append(result.Errors, formatImportError("host", i+1, err)) + continue + } + existing, err := s.store.FindHostByRouteGlobal(h.Host, h.Location) + switch mode { + case "create": + if err == nil { + result.Skipped++ + continue + } + if err := s.store.CreateHost(&h); err != nil { + result.Failed++ + result.Errors = append(result.Errors, formatImportError("host", i+1, err)) + continue + } + s.cache.UpsertHost(&h) + result.Created++ + case "skip": + if err == nil { + result.Skipped++ + continue + } + if err := s.store.CreateHost(&h); err != nil { + result.Failed++ + result.Errors = append(result.Errors, formatImportError("host", i+1, err)) + continue + } + s.cache.UpsertHost(&h) + result.Created++ + default: + if err != nil { + if err := s.store.CreateHost(&h); err != nil { + result.Failed++ + result.Errors = append(result.Errors, formatImportError("host", i+1, err)) + continue + } + s.cache.UpsertHost(&h) + result.Created++ + continue + } + h.ID = existing.ID + h.ClientID = existing.ClientID + if err := s.store.UpdateHost(&h); err != nil { + result.Failed++ + result.Errors = append(result.Errors, formatImportError("host", i+1, err)) + continue + } + s.cache.UpsertHost(&h) + result.Updated++ + } + } + c.JSON(http.StatusOK, result) +} + +func tunnelFromImportItem(item store.TunnelExportItem, clientID uint) store.Tunnel { + return store.Tunnel{ + ClientID: clientID, + Name: item.Name, + Mode: item.Mode, + ListenIP: item.ListenIP, + ListenPort: item.ListenPort, + TargetAddr: item.TargetAddr, + IPForwardMode: item.IPForwardMode, + CustomHeaders: item.CustomHeaders, + Status: item.Status, + } +} + +func hostFromImportItem(item store.HostExportItem, clientID uint) store.Host { + return store.Host{ + ClientID: clientID, + Host: item.Host, + Location: item.Location, + Scheme: item.Scheme, + TargetAddr: item.TargetAddr, + AutoHTTPS: item.AutoHTTPS, + IPForwardMode: item.IPForwardMode, + CustomHeaders: item.CustomHeaders, + Status: item.Status, + } +} + +func normalizeTunnel(t *store.Tunnel) error { + if t.ListenIP == "" { + t.ListenIP = "0.0.0.0" + } + if t.Mode == "" { + t.Mode = "tcp" + } + if t.ListenPort <= 0 || t.TargetAddr == "" { + return errImportField("listen_port and target_addr required") + } + if t.IPForwardMode == "" { + t.IPForwardMode = "replace" + } + return nil +} + +func normalizeHost(h *store.Host) error { + if h.Host == "" || h.TargetAddr == "" { + return errImportField("host and target_addr required") + } + if h.Location == "" { + h.Location = "/" + } + if h.Scheme == "" { + h.Scheme = "all" + } + if h.IPForwardMode == "" { + h.IPForwardMode = "replace" + } + return nil +} + +type importFieldError string + +func errImportField(msg string) error { return importFieldError(msg) } + +func (e importFieldError) Error() string { return string(e) } + +func formatImportError(kind string, index int, err error) string { + return kind + " #" + strconv.Itoa(index) + ": " + err.Error() +} + +func tunnelClientNames(st *store.Store, tunnels []store.Tunnel) map[uint]string { + ids := make(map[uint]struct{}) + for _, t := range tunnels { + ids[t.ClientID] = struct{}{} + } + names := make(map[uint]string, len(ids)) + for id := range ids { + if c, err := st.GetClientByID(id); err == nil { + names[id] = c.Name + } + } + return names +} + +func hostClientNames(st *store.Store, hosts []store.Host) map[uint]string { + ids := make(map[uint]struct{}) + for _, h := range hosts { + ids[h.ClientID] = struct{}{} + } + names := make(map[uint]string, len(ids)) + for id := range ids { + if c, err := st.GetClientByID(id); err == nil { + names[id] = c.Name + } + } + return names +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go new file mode 100644 index 0000000..99a0798 --- /dev/null +++ b/internal/api/middleware.go @@ -0,0 +1,56 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/wormhole/wormhole/internal/auth" + "github.com/wormhole/wormhole/internal/store" +) + +func (s *Server) authMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + if key := c.GetHeader("X-API-Key"); key != "" { + user, err := s.store.ValidateAPIKey(key) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid api key"}) + return + } + c.Set("claims", &auth.Claims{ + UserID: user.ID, + Username: user.Username, + Role: user.Role, + }) + c.Set("auth_via", "apikey") + c.Next() + return + } + + token := c.GetHeader("Authorization") + if len(token) > 7 && token[:7] == "Bearer " { + token = token[7:] + } else { + token = c.Query("token") + } + if token == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + claims, err := s.auth.ParseToken(token) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + c.Set("claims", claims) + c.Set("auth_via", "jwt") + c.Next() + } +} + +func pageParams(c *gin.Context) store.PageParams { + return store.ParsePageQuery( + c.DefaultQuery("page", "1"), + c.DefaultQuery("page_size", "20"), + c.Query("q"), + ) +} diff --git a/internal/api/openapi.go b/internal/api/openapi.go new file mode 100644 index 0000000..4beec95 --- /dev/null +++ b/internal/api/openapi.go @@ -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" } } } } + } +}` diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 0000000..35d1f6e --- /dev/null +++ b/internal/api/server.go @@ -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}) +} diff --git a/internal/api/settings.go b/internal/api/settings.go new file mode 100644 index 0000000..5580f61 --- /dev/null +++ b/internal/api/settings.go @@ -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, + }) +} diff --git a/internal/api/visitor_helpers.go b/internal/api/visitor_helpers.go new file mode 100644 index 0000000..ca0346a --- /dev/null +++ b/internal/api/visitor_helpers.go @@ -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 "" + } +} diff --git a/internal/auth/ip.go b/internal/auth/ip.go new file mode 100644 index 0000000..f141b73 --- /dev/null +++ b/internal/auth/ip.go @@ -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 +} diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go new file mode 100644 index 0000000..619829f --- /dev/null +++ b/internal/auth/jwt.go @@ -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" +} diff --git a/internal/auth/limiter.go b/internal/auth/limiter.go new file mode 100644 index 0000000..7627a4b --- /dev/null +++ b/internal/auth/limiter.go @@ -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) + } +} diff --git a/internal/auth/limiter_test.go b/internal/auth/limiter_test.go new file mode 100644 index 0000000..6623cca --- /dev/null +++ b/internal/auth/limiter_test.go @@ -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") + } +} diff --git a/internal/auth/resource.go b/internal/auth/resource.go new file mode 100644 index 0000000..a59063e --- /dev/null +++ b/internal/auth/resource.go @@ -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(` +
+ + + +
`, 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) +} diff --git a/internal/auth/visitor.go b/internal/auth/visitor.go new file mode 100644 index 0000000..82d026e --- /dev/null +++ b/internal/auth/visitor.go @@ -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(`

%s

`, html.EscapeString(errMsg)) + } + page := fmt.Sprintf(` + + + + +登录验证 + + + +
+

登录验证

+

此资源已开启访客登录,请使用分配的账号访问。

+%s +
+ + + + + + +
+
+ +`, 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(` + + + + +登录验证 + + + +
+

登录验证

+

此资源已开启访客登录,请使用分配的账号访问。

+

%s

+

稍后再试

+
+ +`, 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 + } +} diff --git a/internal/auth/visitor_log.go b/internal/auth/visitor_log.go new file mode 100644 index 0000000..e8402bc --- /dev/null +++ b/internal/auth/visitor_log.go @@ -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) + }() +} diff --git a/internal/bridge/bridge.go b/internal/bridge/bridge.go new file mode 100644 index 0000000..768d58c --- /dev/null +++ b/internal/bridge/bridge.go @@ -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) +} diff --git a/internal/bridge/keepalive.go b/internal/bridge/keepalive.go new file mode 100644 index 0000000..def03f3 --- /dev/null +++ b/internal/bridge/keepalive.go @@ -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 + } + } +} diff --git a/internal/bridge/ratelimit.go b/internal/bridge/ratelimit.go new file mode 100644 index 0000000..1297ef8 --- /dev/null +++ b/internal/bridge/ratelimit.go @@ -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) +} diff --git a/internal/bridge/watchdog.go b/internal/bridge/watchdog.go new file mode 100644 index 0000000..0e0ff71 --- /dev/null +++ b/internal/bridge/watchdog.go @@ -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) +} diff --git a/internal/cache/cache.go b/internal/cache/cache.go new file mode 100644 index 0000000..55ba8de --- /dev/null +++ b/internal/cache/cache.go @@ -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 + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..6562cc2 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..06b8b65 --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } +} diff --git a/internal/metrics/collector.go b/internal/metrics/collector.go new file mode 100644 index 0000000..8df9731 --- /dev/null +++ b/internal/metrics/collector.go @@ -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 +} diff --git a/internal/protocol/protocol.go b/internal/protocol/protocol.go new file mode 100644 index 0000000..18f4d11 --- /dev/null +++ b/internal/protocol/protocol.go @@ -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 +} diff --git a/internal/proxy/headers.go b/internal/proxy/headers.go new file mode 100644 index 0000000..b2ac7f5 --- /dev/null +++ b/internal/proxy/headers.go @@ -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) + } +} diff --git a/internal/proxy/http_tunnel.go b/internal/proxy/http_tunnel.go new file mode 100644 index 0000000..4162ea9 --- /dev/null +++ b/internal/proxy/http_tunnel.go @@ -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 +} diff --git a/internal/proxy/manager.go b/internal/proxy/manager.go new file mode 100644 index 0000000..1608ac5 --- /dev/null +++ b/internal/proxy/manager.go @@ -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) +} diff --git a/internal/proxy/tcp_udp.go b/internal/proxy/tcp_udp.go new file mode 100644 index 0000000..c304ba8 --- /dev/null +++ b/internal/proxy/tcp_udp.go @@ -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 +} diff --git a/internal/security/ipmatcher.go b/internal/security/ipmatcher.go new file mode 100644 index 0000000..86b0849 --- /dev/null +++ b/internal/security/ipmatcher.go @@ -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 +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..6fc9400 --- /dev/null +++ b/internal/server/server.go @@ -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) + } +} diff --git a/internal/store/apikey.go b/internal/store/apikey.go new file mode 100644 index 0000000..52366ee --- /dev/null +++ b/internal/store/apikey.go @@ -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 +} diff --git a/internal/store/export.go b/internal/store/export.go new file mode 100644 index 0000000..ac7eda3 --- /dev/null +++ b/internal/store/export.go @@ -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 +} diff --git a/internal/store/models.go b/internal/store/models.go new file mode 100644 index 0000000..2e756fc --- /dev/null +++ b/internal/store/models.go @@ -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"` +} diff --git a/internal/store/query.go b/internal/store/query.go new file mode 100644 index 0000000..084d86c --- /dev/null +++ b/internal/store/query.go @@ -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} +} diff --git a/internal/store/settings.go b/internal/store/settings.go new file mode 100644 index 0000000..d45f60d --- /dev/null +++ b/internal/store/settings.go @@ -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 +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..b1a2b7a --- /dev/null +++ b/internal/store/store.go @@ -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 +} diff --git a/internal/store/visitor.go b/internal/store/visitor.go new file mode 100644 index 0000000..c5ce090 --- /dev/null +++ b/internal/store/visitor.go @@ -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 +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..31646b0 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,3 @@ +package version + +const Version = "0.1.0" diff --git a/migrations/001_init.sql b/migrations/001_init.sql new file mode 100644 index 0000000..b698710 --- /dev/null +++ b/migrations/001_init.sql @@ -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 +); diff --git a/migrations/002_proxy_headers.sql b/migrations/002_proxy_headers.sql new file mode 100644 index 0000000..9ba1f0c --- /dev/null +++ b/migrations/002_proxy_headers.sql @@ -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 ''; diff --git a/migrations/003_system_settings.sql b/migrations/003_system_settings.sql new file mode 100644 index 0000000..b9ded79 --- /dev/null +++ b/migrations/003_system_settings.sql @@ -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 +); diff --git a/web/.npmrc b/web/.npmrc new file mode 100644 index 0000000..7549542 --- /dev/null +++ b/web/.npmrc @@ -0,0 +1 @@ +registry=https://registry.npmmirror.com diff --git a/web/dist/index.html b/web/dist/index.html new file mode 100644 index 0000000..5853487 --- /dev/null +++ b/web/dist/index.html @@ -0,0 +1,10 @@ + + + + + Wormhole + + +

Wormhole API running. Build web UI: make web

+ + diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..29c8403 --- /dev/null +++ b/web/embed.go @@ -0,0 +1,6 @@ +package web + +import "embed" + +//go:embed dist/* +var Dist embed.FS diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..82e05f8 --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + Wormhole + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..85ad588 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1846 @@ +{ + "name": "wormhole-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wormhole-web", + "version": "0.1.0", + "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" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@intlify/core-base": { + "version": "9.14.4", + "resolved": "https://registry.npmmirror.com/@intlify/core-base/-/core-base-9.14.4.tgz", + "integrity": "sha512-vtZCt7NqWhKEtHa3SD/322DlgP5uR9MqWxnE0y8Q0tjDs9H5Lxhss+b5wv8rmuXRoHKLESNgw9d+EN9ybBbj9g==", + "license": "MIT", + "dependencies": { + "@intlify/message-compiler": "9.14.4", + "@intlify/shared": "9.14.4" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/message-compiler": { + "version": "9.14.4", + "resolved": "https://registry.npmmirror.com/@intlify/message-compiler/-/message-compiler-9.14.4.tgz", + "integrity": "sha512-vcyCLiVRN628U38c3PbahrhbbXrckrM9zpy0KZVlDk2Z0OnGwv8uQNNXP3twwGtfLsCf4gu3ci6FMIZnPaqZsw==", + "license": "MIT", + "dependencies": { + "@intlify/shared": "9.14.4", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/shared": { + "version": "9.14.4", + "resolved": "https://registry.npmmirror.com/@intlify/shared/-/shared-9.14.4.tgz", + "integrity": "sha512-P9zv6i1WvMc9qDBWvIgKkymjY2ptIiQ065PjDv7z7fDqH3J/HBRBN5IoiR46r/ujRcU7hCuSIZWvCAFCyuOYZA==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", + "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", + "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", + "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", + "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", + "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", + "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", + "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", + "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", + "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", + "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", + "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", + "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", + "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", + "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", + "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", + "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", + "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", + "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", + "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", + "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", + "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", + "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", + "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", + "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", + "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.38.tgz", + "integrity": "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.38", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.38.tgz", + "integrity": "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.38.tgz", + "integrity": "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.38", + "@vue/compiler-dom": "3.5.38", + "@vue/compiler-ssr": "3.5.38", + "@vue/shared": "3.5.38", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.38.tgz", + "integrity": "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.38.tgz", + "integrity": "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.38.tgz", + "integrity": "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.38.tgz", + "integrity": "sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.38", + "@vue/runtime-core": "3.5.38", + "@vue/shared": "3.5.38", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.38.tgz", + "integrity": "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.38", + "@vue/shared": "3.5.38" + }, + "peerDependencies": { + "vue": "3.5.38" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.38.tgz", + "integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "14.3.0", + "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.3.0.tgz", + "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.3.0", + "@vueuse/shared": "14.3.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.3.0", + "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.3.0.tgz", + "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.3.0", + "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.3.0.tgz", + "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.0", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.18.0.tgz", + "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/element-plus": { + "version": "2.14.2", + "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.14.2.tgz", + "integrity": "sha512-eNH9uP3wQoNqieEIHXiNvIVv+zO5sZDU0CAZq5b0zqSN06DD0/V9xIq1R/qm3rw5k3nBTM1JvpxhCfRbaFLzDQ==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.7.6", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8", + "@types/lodash": "^4.17.24", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "14.3.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.20", + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.3.3" + }, + "peerDependencies": { + "vue": "^3.3.7" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rollup": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.0.tgz", + "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.0", + "@rollup/rollup-android-arm64": "4.62.0", + "@rollup/rollup-darwin-arm64": "4.62.0", + "@rollup/rollup-darwin-x64": "4.62.0", + "@rollup/rollup-freebsd-arm64": "4.62.0", + "@rollup/rollup-freebsd-x64": "4.62.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", + "@rollup/rollup-linux-arm-musleabihf": "4.62.0", + "@rollup/rollup-linux-arm64-gnu": "4.62.0", + "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@rollup/rollup-linux-loong64-gnu": "4.62.0", + "@rollup/rollup-linux-loong64-musl": "4.62.0", + "@rollup/rollup-linux-ppc64-gnu": "4.62.0", + "@rollup/rollup-linux-ppc64-musl": "4.62.0", + "@rollup/rollup-linux-riscv64-gnu": "4.62.0", + "@rollup/rollup-linux-riscv64-musl": "4.62.0", + "@rollup/rollup-linux-s390x-gnu": "4.62.0", + "@rollup/rollup-linux-x64-gnu": "4.62.0", + "@rollup/rollup-linux-x64-musl": "4.62.0", + "@rollup/rollup-openbsd-x64": "4.62.0", + "@rollup/rollup-openharmony-arm64": "4.62.0", + "@rollup/rollup-win32-arm64-msvc": "4.62.0", + "@rollup/rollup-win32-ia32-msvc": "4.62.0", + "@rollup/rollup-win32-x64-gnu": "4.62.0", + "@rollup/rollup-win32-x64-msvc": "4.62.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.38.tgz", + "integrity": "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.38", + "@vue/compiler-sfc": "3.5.38", + "@vue/runtime-dom": "3.5.38", + "@vue/server-renderer": "3.5.38", + "@vue/shared": "3.5.38" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.5", + "resolved": "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-3.3.5.tgz", + "integrity": "sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==", + "license": "MIT" + }, + "node_modules/vue-i18n": { + "version": "9.14.4", + "resolved": "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-9.14.4.tgz", + "integrity": "sha512-B934C8yUyWLT0EMud3DySrwSUJI7ZNiWYsEEz2gknTthqKiG4dzWE/WSa8AzCuSQzwBEv4HtG1jZDhgzPfWSKQ==", + "license": "MIT", + "dependencies": { + "@intlify/core-base": "9.14.4", + "@intlify/shared": "9.14.4", + "@vue/devtools-api": "^6.5.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..99fd335 --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..b079d51 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/web/src/App.vue b/web/src/App.vue new file mode 100644 index 0000000..d94ed87 --- /dev/null +++ b/web/src/App.vue @@ -0,0 +1,15 @@ + + + diff --git a/web/src/api/index.js b/web/src/api/index.js new file mode 100644 index 0000000..a765ff7 --- /dev/null +++ b/web/src/api/index.js @@ -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 diff --git a/web/src/components/AppCard.vue b/web/src/components/AppCard.vue new file mode 100644 index 0000000..61b900a --- /dev/null +++ b/web/src/components/AppCard.vue @@ -0,0 +1,19 @@ + + + diff --git a/web/src/components/AppLogo.vue b/web/src/components/AppLogo.vue new file mode 100644 index 0000000..487a6ab --- /dev/null +++ b/web/src/components/AppLogo.vue @@ -0,0 +1,33 @@ + + + diff --git a/web/src/components/ChartBox.vue b/web/src/components/ChartBox.vue new file mode 100644 index 0000000..66580e7 --- /dev/null +++ b/web/src/components/ChartBox.vue @@ -0,0 +1,37 @@ + + + diff --git a/web/src/components/ClientSelect.vue b/web/src/components/ClientSelect.vue new file mode 100644 index 0000000..5c394a7 --- /dev/null +++ b/web/src/components/ClientSelect.vue @@ -0,0 +1,36 @@ + + + diff --git a/web/src/components/ImportExportActions.vue b/web/src/components/ImportExportActions.vue new file mode 100644 index 0000000..e876b90 --- /dev/null +++ b/web/src/components/ImportExportActions.vue @@ -0,0 +1,109 @@ + + + diff --git a/web/src/components/PageHeader.vue b/web/src/components/PageHeader.vue new file mode 100644 index 0000000..3b984b0 --- /dev/null +++ b/web/src/components/PageHeader.vue @@ -0,0 +1,18 @@ + + + diff --git a/web/src/components/ProxyHeadersFields.vue b/web/src/components/ProxyHeadersFields.vue new file mode 100644 index 0000000..0d01afa --- /dev/null +++ b/web/src/components/ProxyHeadersFields.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/web/src/components/RankDialog.vue b/web/src/components/RankDialog.vue new file mode 100644 index 0000000..307f896 --- /dev/null +++ b/web/src/components/RankDialog.vue @@ -0,0 +1,74 @@ + + + diff --git a/web/src/components/StatCard.vue b/web/src/components/StatCard.vue new file mode 100644 index 0000000..cdf45ed --- /dev/null +++ b/web/src/components/StatCard.vue @@ -0,0 +1,13 @@ + + + diff --git a/web/src/components/VisitorAuthFields.vue b/web/src/components/VisitorAuthFields.vue new file mode 100644 index 0000000..10de20b --- /dev/null +++ b/web/src/components/VisitorAuthFields.vue @@ -0,0 +1,73 @@ + + + diff --git a/web/src/composables/useMetricHistory.js b/web/src/composables/useMetricHistory.js new file mode 100644 index 0000000..831b26e --- /dev/null +++ b/web/src/composables/useMetricHistory.js @@ -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 } +} diff --git a/web/src/i18n/index.js b/web/src/i18n/index.js new file mode 100644 index 0000000..cb152b9 --- /dev/null +++ b/web/src/i18n/index.js @@ -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 diff --git a/web/src/layouts/MainLayout.vue b/web/src/layouts/MainLayout.vue new file mode 100644 index 0000000..d10263e --- /dev/null +++ b/web/src/layouts/MainLayout.vue @@ -0,0 +1,136 @@ + + + diff --git a/web/src/locales/en.js b/web/src/locales/en.js new file mode 100644 index 0000000..51c5da6 --- /dev/null +++ b/web/src/locales/en.js @@ -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', + }, +} diff --git a/web/src/locales/zh-CN.js b/web/src/locales/zh-CN.js new file mode 100644 index 0000000..0537eb1 --- /dev/null +++ b/web/src/locales/zh-CN.js @@ -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: '已加入黑名单', + }, +} diff --git a/web/src/main.js b/web/src/main.js new file mode 100644 index 0000000..3c7bbaf --- /dev/null +++ b/web/src/main.js @@ -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') diff --git a/web/src/router/index.js b/web/src/router/index.js new file mode 100644 index 0000000..d9d321c --- /dev/null +++ b/web/src/router/index.js @@ -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 diff --git a/web/src/styles/theme.css b/web/src/styles/theme.css new file mode 100644 index 0000000..b7c01e2 --- /dev/null +++ b/web/src/styles/theme.css @@ -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; } +} diff --git a/web/src/utils/chartTheme.js b/web/src/utils/chartTheme.js new file mode 100644 index 0000000..efa2b4e --- /dev/null +++ b/web/src/utils/chartTheme.js @@ -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, + })), + } +} diff --git a/web/src/utils/datetime.js b/web/src/utils/datetime.js new file mode 100644 index 0000000..1785dbd --- /dev/null +++ b/web/src/utils/datetime.js @@ -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}` +} diff --git a/web/src/utils/format.js b/web/src/utils/format.js new file mode 100644 index 0000000..0ae0920 --- /dev/null +++ b/web/src/utils/format.js @@ -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]}` +} diff --git a/web/src/utils/importExport.js b/web/src/utils/importExport.js new file mode 100644 index 0000000..6bdb2d9 --- /dev/null +++ b/web/src/utils/importExport.js @@ -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 +} diff --git a/web/src/utils/ipForward.js b/web/src/utils/ipForward.js new file mode 100644 index 0000000..4534638 --- /dev/null +++ b/web/src/utils/ipForward.js @@ -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`) +} diff --git a/web/src/utils/targetLink.js b/web/src/utils/targetLink.js new file mode 100644 index 0000000..dbcc3f7 --- /dev/null +++ b/web/src/utils/targetLink.js @@ -0,0 +1,43 @@ +/** 将 target_addr 转为可打开的 URL */ +export function targetHref(addr) { + if (!addr) return '' + const trimmed = String(addr).trim() + if (/^https?:\/\//i.test(trimmed)) return trimmed + return `http://${trimmed}` +} + +/** 域名解析:点击域名打开对外访问地址 */ +export function hostHref(row) { + const domain = row?.host?.trim() + if (!domain) return '' + if (/^https?:\/\//i.test(domain)) return domain + const useHttps = row.auto_https || row.scheme === 'https' + const scheme = useHttps ? 'https' : 'http' + let loc = (row.location || '/').trim() + if (!loc.startsWith('/')) loc = `/${loc}` + const suffix = loc === '/' ? '' : loc + return `${scheme}://${domain}${suffix}` +} + +/** 隧道:点击监听端口打开服务端对外入口 */ +export function tunnelHref(row) { + const port = row?.listen_port + if (!port) return '' + const host = window.location.hostname || 'localhost' + return `http://${host}:${port}` +} + +export function openTarget(addr) { + const url = targetHref(addr) + if (url) window.open(url, '_blank', 'noopener,noreferrer') +} + +export function openHost(row) { + const url = hostHref(row) + if (url) window.open(url, '_blank', 'noopener,noreferrer') +} + +export function openTunnel(row) { + const url = tunnelHref(row) + if (url) window.open(url, '_blank', 'noopener,noreferrer') +} diff --git a/web/src/views/Clients.vue b/web/src/views/Clients.vue new file mode 100644 index 0000000..be7aa44 --- /dev/null +++ b/web/src/views/Clients.vue @@ -0,0 +1,116 @@ + + + diff --git a/web/src/views/Dashboard.vue b/web/src/views/Dashboard.vue new file mode 100644 index 0000000..c68de5d --- /dev/null +++ b/web/src/views/Dashboard.vue @@ -0,0 +1,155 @@ + + + diff --git a/web/src/views/Hosts.vue b/web/src/views/Hosts.vue new file mode 100644 index 0000000..6356d4d --- /dev/null +++ b/web/src/views/Hosts.vue @@ -0,0 +1,201 @@ + + + diff --git a/web/src/views/IPRules.vue b/web/src/views/IPRules.vue new file mode 100644 index 0000000..51a6b5f --- /dev/null +++ b/web/src/views/IPRules.vue @@ -0,0 +1,253 @@ + + + diff --git a/web/src/views/Login.vue b/web/src/views/Login.vue new file mode 100644 index 0000000..327d695 --- /dev/null +++ b/web/src/views/Login.vue @@ -0,0 +1,98 @@ + + + diff --git a/web/src/views/RequestIPs.vue b/web/src/views/RequestIPs.vue new file mode 100644 index 0000000..b1fc718 --- /dev/null +++ b/web/src/views/RequestIPs.vue @@ -0,0 +1,86 @@ + + + diff --git a/web/src/views/Settings.vue b/web/src/views/Settings.vue new file mode 100644 index 0000000..979755a --- /dev/null +++ b/web/src/views/Settings.vue @@ -0,0 +1,229 @@ + + + diff --git a/web/src/views/Tunnels.vue b/web/src/views/Tunnels.vue new file mode 100644 index 0000000..f7b44ed --- /dev/null +++ b/web/src/views/Tunnels.vue @@ -0,0 +1,218 @@ + + + diff --git a/web/src/views/Users.vue b/web/src/views/Users.vue new file mode 100644 index 0000000..ee29a00 --- /dev/null +++ b/web/src/views/Users.vue @@ -0,0 +1,148 @@ + + + diff --git a/web/vite.config.js b/web/vite.config.js new file mode 100644 index 0000000..6207fda --- /dev/null +++ b/web/vite.config.js @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { fileURLToPath, URL } from 'node:url' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + build: { + outDir: 'dist', + emptyOutDir: true, + }, + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://127.0.0.1:8080', + changeOrigin: true, + }, + }, + }, +})